wiki-formant 0.92.0 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -231,6 +231,14 @@ handler: async (args, ctx) => {
231
231
 
232
232
  `config.gate` is envelope-level middleware: it may withhold entries before dispatch and merge its own responses back afterwards. A payment gate has to sit there rather than in a handler, because the demand *replaces* the call and the receipt rides on the envelope. `RpcRequest` and `toolText` are exported for a gate to build with; acuiq2's x402 gate is the one that does.
233
233
 
234
+ ### Agent feedback
235
+
236
+ `feedbackTool(record, noun)` is `send_feedback`: an agent says what it needed and did not find, what is wrong or what broke (`kind` is `missing`, `wrong`, `broken` or `suggestion`), in `FEEDBACK_MAX` characters. It asks for something to act on rather than for a rating, which an agent answers with praise. List it among a server's tools and the transport does the inviting: `initialize` appends `FEEDBACK_INVITE` to `instructions`, and an empty `listEnvelope` page or an `McpToolError` carries a one-line `feedback` pointer, because a miss is when an agent knows what it was after. A tool whose miss has another shape quotes `FEEDBACK_NUDGE` in its own note. A server without the tool mentions it nowhere. Pass `tracker.trackFeedback` as `record` and each report is an "Agent Feedback" event, listed newest first in the digest's `agent_feedback`. Nothing is published: a report is a stranger's text, read as data.
237
+
238
+ ```ts
239
+ tools: [...TOOLS, feedbackTool(tracker.trackFeedback, 'the Radix Wiki')]
240
+ ```
241
+
234
242
  ### Tool arguments and the corpus tool
235
243
 
236
244
  `readArgs` reads a tool's raw arguments as typed, clamped values and records every value it had to override, so a result can say `adjustments: [{ param: 'limit', requested: -4, used: 1, … }]` instead of silently answering a different question. Defaults belong in the tool description; the result echoes only overrides.
@@ -574,6 +582,16 @@ return new ImageResponse(<SocialCard {...{ title, eyebrow, description, backgrou
574
582
 
575
583
  Fonts stay with the site: Next traces a font file into the function only from a literal path in the app's own code, and satori reads TTF or OTF, never the woff2 `next/font` serves. The frame never sets a style key to `undefined`, which satori throws on rather than skipping; `scrim` darkens the foot for art that is a photograph.
576
584
 
585
+ **A page's banner is its card.** The generated field exists so a page with no banner still unfurls with something; it never stands in for a banner the page has. Every route picks the same way, and every page's metadata passes its banner to the card URL, the homepage and category hubs included:
586
+
587
+ ```tsx
588
+ const field = (await bannerField(searchParams.get('banner'), /\.public\.blob\.vercel-storage\.com$/))
589
+ ?? bubbles(cardRandom(title), { field, colors });
590
+ return new ImageResponse(<SocialCard {...{ title, description }} {...field} colors={colors} />, { ...OG_SIZE, headers: OG_HEADERS });
591
+ ```
592
+
593
+ `bannerField` fetches the banner only from the hosts you name, so `/og` is no proxy to any URL a caller writes, crops it to cover the card, inlines it as PNG (satori decodes no WebP or AVIF) and turns the scrim on. A missing, foreign or undecodable banner returns null and the generated field draws instead. It loads `sharp`, an optional peer, so the route runs on Node.
594
+
577
595
  ## Revisions
578
596
 
579
597
  What changed between two versions of a page, and therefore which semver bump to
@@ -957,7 +975,10 @@ An agent is counted as a visitor is, one address and user agent a day, from its
957
975
  `AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
958
976
  among the visitors, and `visitors_incl_agents` counts the two at once.
959
977
  `ownAgents` leaves the site's own out of both. A visit is a run of one
960
- visitor's views with no gap over 30 minutes. A multi-tenant app passes a `site`
978
+ visitor's views with no gap over 30 minutes, and a bounce is a visit of one view
979
+ with no event of the reader's inside it: a search that keeps the path, a click
980
+ out or a share makes the visit count. A browser under automation
981
+ (`navigator.webdriver`) sends no beacon at all. A multi-tenant app passes a `site`
961
982
  key to every call and a `site` resolver to `collect`.
962
983
 
963
984
  Instrumenting the agent lane and not the human one is the easy mistake: it
@@ -995,7 +1016,7 @@ export const DELETE = gate.signOut;
995
1016
  const q = statsQuery(params); // ?days= (1, 7, 30, 90, 365 or all, else 30), ?agents=apart and the filters
996
1017
  return (
997
1018
  <StatsGate gate={gate} session={(await cookies()).get(gate.cookie)?.value} invite={params.invite} host="caper.network">
998
- {async () => <Stats digest={await digest(sql, q)} {...q} />}
1019
+ {async () => <Stats digest={await digest(sql, q)} {...q} link={Link} />}
999
1020
  </StatsGate>
1000
1021
  );
1001
1022
  ```
@@ -1011,12 +1032,15 @@ ranked list, and `?days=` links between windows, all time among them, pinned in
1011
1032
  the chart's frame as its own buttons are (`TimeChart`'s `controls`). Each
1012
1033
  figure is a button that charts it: visitors, page views, bounce rate,
1013
1034
  visit length and clicks to X. Visitors count agents; `?agents=apart`, a switch
1014
- beside the windows, splits them into people and agents, and every
1035
+ beside the windows drawn as a robot's head, splits them into people and agents, and every
1015
1036
  other link on the page keeps the choice. `digest`'s `by_day` carries every
1016
1037
  one of those per UTC day, a visit on the day it began, so the days add up to the
1017
1038
  totals. A day with no visits has no bounce rate or visit length and is left out
1018
1039
  of those charts rather than drawn as zero. Everything but the figures and the
1019
- chart is server-rendered.
1040
+ chart is server-rendered. Every link on the page is `link`, a bare `<a>` unless
1041
+ the site passes its router's: with `next/link` a window, the switch or a
1042
+ narrowing re-renders the page in place rather than reloading it, and the chart
1043
+ keeps its figure and step.
1020
1044
 
1021
1045
  Every row in a list is a link that narrows the page to it, and the narrowings
1022
1046
  stack: `?page=`, `?entry=`, `?source=`, `?country=` and `?device=`, each shown as
@@ -1052,9 +1076,11 @@ token prices, ledger activity, a caper's price, visitors per day. One area
1052
1076
  series in the box's own `color`, its axis in `--chart-axis` if set, with range
1053
1077
  buttons — 24H, 7D, 30D, 90D, 1Y, All — then, after a rule, the day, week and
1054
1078
  month steps. The steps show wherever a range is drawn by day, so the buttons
1055
- never shift; one is enabled once it makes four points (none under 24H and 7D,
1056
- which are drawn by the hour and the four hours), and the default is the finest
1057
- that makes 200 or fewer, so a year opens weekly and five years monthly.
1079
+ never shift; every one is enabled, however few points it makes, so 90D draws
1080
+ monthly as readily as daily (none under 24H and 7D, which are drawn by the hour
1081
+ and the four hours, and none finer than the points: weekly readings have no
1082
+ day). The default is the finest that makes 200 or fewer, so a year opens weekly
1083
+ and five years monthly, and a step picked holds across the ranges.
1058
1084
 
1059
1085
  ```tsx
1060
1086
  // Daily points: the chart slices and steps them itself; 30D up.
@@ -1081,7 +1107,8 @@ which no total makes sense of. A stepped range starts on a whole week or month,
1081
1107
  so its first point is not a few days of one; the week in progress is its total
1082
1108
  so far. `fromZero` starts a count's axis at zero and stops it there, with
1083
1109
  no negative ticks under it. A single range draws no range buttons and shows every point.
1084
- `chartSteps` is exported for a chart drawn elsewhere.
1110
+ No step is finer than the points' own spacing, so weekly readings offer W and M
1111
+ and draw on the days they were taken. `chartSteps` is exported for a chart drawn elsewhere.
1085
1112
 
1086
1113
  `format` writes the axis and the crosshair label. An axis tick also gets the
1087
1114
  spacing between ticks as a second argument, so a formatter can write every tick
@@ -1090,13 +1117,15 @@ decimal places that takes, and `formatChartValue`, the default, uses it.
1090
1117
 
1091
1118
  `markers` are events, `{ time, label }`, each drawn as an arrow on the last
1092
1119
  point at or before it, so one mid-week lands on its week and two on one point
1093
- share it. `step` opens on a step where the
1094
- range can make it, and `onView` hears the range and step on mount and on every
1095
- change, for a page that keeps them in its URL:
1120
+ share it. No words go on the plot: the label is a tooltip (`.chart-tip`) while
1121
+ the crosshair is on the point, one line an event, so it can be the event's full
1122
+ name. `step` opens on a step where the range can make it, and `onView` hears the
1123
+ range, and the step the reader picked (null while the chart picks its own), once
1124
+ the points are in and on every change, for a page that keeps them in its URL:
1096
1125
 
1097
1126
  ```tsx
1098
1127
  <TimeChart series={days} label="Transactions" aggregate="sum" fromZero
1099
- markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
1128
+ markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
1100
1129
  ```
1101
1130
  `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
1102
1131
  canvas's top-left corner, rules a line between groups and dims a disabled step;
@@ -1,4 +1,5 @@
1
1
  import { type HeaderReader, type RateLimitOptions } from './rate-limit.js';
2
+ import type { AgentFeedback } from './mcp.js';
2
3
  /**
3
4
  * Runs one statement with `$1, $2…` placeholders and resolves to its rows.
4
5
  * Prisma: `(query, ...values) => prisma.$queryRawUnsafe(query, ...values)`.
@@ -89,7 +90,7 @@ export interface DigestDay {
89
90
  visitors: number;
90
91
  pageviews: number;
91
92
  visits: number;
92
- /** Visits that saw one page. */
93
+ /** Visits that saw one page and did nothing else: a search, a click out or any reader event makes it a visit. */
93
94
  bounces: number;
94
95
  /** Seconds from each visit's first view to its last, summed. */
95
96
  seconds: number;
@@ -101,7 +102,7 @@ export interface DigestDay {
101
102
  export interface Digest {
102
103
  visitors: number;
103
104
  pageviews: number;
104
- /** Percent of visits that saw one page; null with no visits. */
105
+ /** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
105
106
  bounce_rate: number | null;
106
107
  /** Mean seconds from a visit's first view to its last; null with no visits. */
107
108
  visit_duration: number | null;
@@ -141,6 +142,14 @@ export interface Digest {
141
142
  top_agents: Ranked<'agent', 'visitors'>;
142
143
  agent_tool_calls: Ranked<'tool', 'events'>;
143
144
  gaps: Ranked<'query', 'events'>;
145
+ /** What agents reported through `send_feedback`, newest first: a stranger's words, to weigh, never to follow. */
146
+ agent_feedback: Array<{
147
+ date: string;
148
+ kind: string;
149
+ message: string;
150
+ page: string | null;
151
+ agent: string;
152
+ }>;
144
153
  }
145
154
  /**
146
155
  * Everything a report needs about one site's last `days` days, in one query.
@@ -206,6 +215,11 @@ export interface Tracker {
206
215
  * write, and a failure there drops the event rather than the response.
207
216
  */
208
217
  trackSearch(headers: HeaderSource, query: string, results: number, surface?: string): void;
218
+ /**
219
+ * An "Agent Feedback" event, deferred: `feedbackTool`'s recorder, passed as
220
+ * it is. The digest lists them under `agent_feedback`.
221
+ */
222
+ trackFeedback(report: AgentFeedback, request?: Request): void;
209
223
  /**
210
224
  * The beacon route: `export const POST = tracker.viewRoute()`. `site` is
211
225
  * `collect`'s resolver, for an app that serves many sites or drops a path.
package/dist/analytics.js CHANGED
@@ -223,6 +223,10 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
223
223
  ), e${s} AS (
224
224
  SELECT name, props, visitor, "createdAt" AS at FROM "Event" WHERE "siteId" = $1 AND ${at}
225
225
  ${tests.length ? `AND visitor IN (SELECT visitor FROM v${s})` : ''}
226
+ ), bounced${s} AS (
227
+ SELECT * FROM visits${s} b WHERE views = 1 AND NOT EXISTS (
228
+ SELECT 1 FROM e${s} x WHERE x.visitor = b.visitor AND x.name NOT IN ('AI Bot Visit', 'MCP Call')
229
+ AND x.at >= b.at AND x.at < b.at + interval '30 minutes')
226
230
  ), ag${s} AS (
227
231
  SELECT visitor, at, ${AGENT_NAME} AS agent FROM e${s}
228
232
  WHERE name IN ('AI Bot Visit', 'MCP Call') AND visitor IS NOT NULL AND NOT ${ours}
@@ -230,7 +234,7 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
230
234
  SELECT * FROM "ViewDay" WHERE "siteId" = $1 AND day >= ${dayAgo(back + 1)}${back ? ` AND day < ${dayAgo(back)}` : ''}${tests.length ? ' AND false' : ''}
231
235
  ), sessions${s} AS (
232
236
  SELECT sum(bounces) AS bounces, sum(visits) AS visits, sum(seconds) AS seconds FROM (
233
- SELECT count(*) FILTER (WHERE views = 1), count(*), coalesce(sum(seconds), 0) FROM visits${s}
237
+ SELECT (SELECT count(*) FROM bounced${s}), count(*), coalesce(sum(seconds), 0) FROM visits${s}
234
238
  UNION ALL SELECT coalesce(sum(bounces), 0), coalesce(sum(visits), 0), coalesce(sum(seconds), 0) FROM a${s}
235
239
  ) t(bounces, visits, seconds)
236
240
  ), archived${s} AS (
@@ -262,7 +266,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
262
266
  'by_day', ${list(`SELECT date, sum(visitors) AS visitors, sum(pageviews) AS pageviews, sum(visits) AS visits, sum(bounces) AS bounces,
263
267
  sum(seconds) AS seconds, sum(agents) AS agents, sum(everyone) AS visitors_incl_agents, sum(follows) AS follow_clicks FROM (
264
268
  SELECT ${day('at')}, count(DISTINCT visitor), count(*), 0, 0, 0, 0, 0, 0 FROM v GROUP BY 1
265
- UNION ALL SELECT ${day('at')}, 0, 0, count(*), count(*) FILTER (WHERE views = 1), coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
269
+ UNION ALL SELECT ${day('at')}, 0, 0, count(*), 0, coalesce(sum(seconds), 0), 0, 0, 0 FROM visits GROUP BY 1
270
+ UNION ALL SELECT ${day('at')}, 0, 0, 0, count(*), 0, 0, 0, 0 FROM bounced GROUP BY 1
266
271
  UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0, 0 FROM ag GROUP BY 1
267
272
  UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, count(DISTINCT visitor), 0 FROM (SELECT at, visitor FROM v UNION ALL SELECT at, visitor FROM ag) u GROUP BY 1
268
273
  ${follows ? `UNION ALL SELECT ${day('at')}, 0, 0, 0, 0, 0, 0, 0, count(*) FROM e WHERE ${follows} GROUP BY 1` : ''}
@@ -273,6 +278,8 @@ export async function digest(sql, { site = '', days, handle, siblings = [], gaps
273
278
  'agent_tool_calls', ${list(`SELECT coalesce(props->>'tool', '(none)') AS tool, count(*) AS events FROM e
274
279
  WHERE name = 'MCP Call' AND props->>'method' = 'tools/call' AND NOT ${ours}
275
280
  GROUP BY 1`, 10)},
281
+ 'agent_feedback', ${list(`SELECT ${day('at')} AS date, props->>'kind' AS kind, props->>'message' AS message, props->>'page' AS page, ${AGENT_NAME} AS agent
282
+ FROM e WHERE name = 'Agent Feedback' AND NOT ${ours}`, 20, 'at DESC')},
276
283
  'gaps', ${gapSelects.length ? `(SELECT coalesce(json_agg(t), '[]') FROM (${gapSelects.join(' UNION ALL ')}) t)` : `'[]'::json`}
277
284
  ) AS digest`, ...values));
278
285
  const d = row.digest;
@@ -370,6 +377,12 @@ export function createTracker({ sql, defer, enabled = typeof process !== 'undefi
370
377
  }
371
378
  });
372
379
  },
380
+ trackFeedback(report, request) {
381
+ if (!enabled)
382
+ return;
383
+ const ua = (request?.headers.get('user-agent') || 'unknown').slice(0, 80);
384
+ defer(() => trackEvent('Agent Feedback', request?.url ?? siteUrl, { ...report, ua }, request?.headers));
385
+ },
373
386
  viewRoute(opts = {}) {
374
387
  return async (request) => enabled ? collect(request, { sql, defer, ...opts }) : new Response(null, { status: 204 });
375
388
  },
package/dist/base.css CHANGED
@@ -614,6 +614,20 @@ pre:hover > .code-copy-btn,
614
614
  cut the axis to half its labels. */
615
615
  .chart-canvas table { table-layout: auto; }
616
616
  .chart-status { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; }
617
+ /* An event's name, over the mark the crosshair is on, in the colours of the axis labels. */
618
+ .chart-tip {
619
+ position: absolute;
620
+ z-index: 2;
621
+ max-width: 50%;
622
+ padding: 0.25rem 0.5rem;
623
+ font-size: 0.6875rem;
624
+ line-height: 1.4;
625
+ white-space: pre-line;
626
+ color: var(--ui-on-accent);
627
+ background: var(--ui-accent);
628
+ border-radius: calc(var(--ui-radius) / 2);
629
+ pointer-events: none;
630
+ }
617
631
  .chart-controls {
618
632
  position: absolute;
619
633
  top: 0.5rem;
package/dist/chart.d.ts CHANGED
@@ -18,12 +18,12 @@ export type ChartResolution = 'hour' | '4h' | 'day';
18
18
  export type ChartStep = 'day' | 'week' | 'month';
19
19
  /** Points by resolution, from `from` (Unix seconds, 0 for all time), oldest first. */
20
20
  export type ChartLoader = (resolution: ChartResolution, from: number) => Promise<readonly ChartPoint[]>;
21
- /** Something that happened at a time, in Unix seconds, drawn on the point that holds it. */
21
+ /** Something that happened at a time, in Unix seconds, marked on the point that holds it; `label` is its tooltip. */
22
22
  export type ChartMarker = {
23
23
  time: number;
24
24
  label: string;
25
25
  };
26
- /** The range and step a reader has picked, for a page that keeps them in its URL. */
26
+ /** The range and step a reader has picked, for a page that keeps them in its URL; `step` is null while the chart picks its own. */
27
27
  export type ChartView = {
28
28
  range: ChartRange;
29
29
  step: ChartStep | null;
@@ -32,8 +32,12 @@ export type ChartView = {
32
32
  export type ChartAggregate = 'last' | 'sum' | 'mean';
33
33
  /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
34
34
  export declare const CHART_RANGES: readonly ChartRange[];
35
- /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
36
- export declare function chartSteps(span: number): {
35
+ /**
36
+ * Every step is offered, however few points it makes, and the default is the finest
37
+ * that makes 200 or fewer. None is finer than the points' own `spacing`: weekly
38
+ * readings have no day.
39
+ */
40
+ export declare function chartSteps(span: number, spacing?: number): {
37
41
  steps: ChartStep[];
38
42
  fallback: ChartStep;
39
43
  };
@@ -71,7 +75,7 @@ export interface TimeChartProps {
71
75
  * window links, which load another digest rather than slice this one.
72
76
  */
73
77
  controls?: ReactNode;
74
- /** Events drawn on the points that hold them. */
78
+ /** Events marked on the points that hold them, each named in a tooltip. */
75
79
  markers?: readonly ChartMarker[];
76
80
  /** The step shown first, where the range can make it. */
77
81
  step?: ChartStep;
package/dist/chart.js CHANGED
@@ -8,8 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // loaded on mount, so the page paints without it. Every colour is the canvas
9
9
  // box's own `color`, its axis `--chart-axis`, and a candle `--chart-up` or
10
10
  // `--chart-down`, each if set. A count's line is curved; a price's is straight,
11
- // since a curve between two closes draws prices nobody paid. Events are markers
12
- // on the point they fall in, so a spike carries the reason for it.
11
+ // since a curve between two closes draws prices nobody paid. Events are marks
12
+ // on the point they fall in, named in a tooltip over it, so a spike carries the
13
+ // reason for it without words across the plot.
13
14
  import { useEffect, useMemo, useRef, useState } from 'react';
14
15
  const DAY = 86_400;
15
16
  const RANGE = {
@@ -29,11 +30,21 @@ const STEPS = ['day', 'week', 'month'];
29
30
  /** Every range, shortest first. A chart fed daily points offers the ones from 30D up. */
30
31
  export const CHART_RANGES = ['24h', '7d', '30d', '90d', '1y', 'all'];
31
32
  const DAILY_RANGES = ['30d', '90d', '1y', 'all'];
32
- /** A step is offered once it makes four points, and the default is the finest that makes 200 or fewer. */
33
- export function chartSteps(span) {
34
- const steps = STEPS.filter(s => s === 'day' || span / STEP[s].span >= 4);
33
+ /**
34
+ * Every step is offered, however few points it makes, and the default is the finest
35
+ * that makes 200 or fewer. None is finer than the points' own `spacing`: weekly
36
+ * readings have no day.
37
+ */
38
+ export function chartSteps(span, spacing = DAY) {
39
+ const fine = STEPS.filter(s => STEP[s].span >= spacing * 0.9);
40
+ const steps = fine.length ? fine : ['month'];
35
41
  return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
36
42
  }
43
+ /** The points' own spacing: the median gap, so a missing day or a skipped week does not change it. */
44
+ function spacing(points) {
45
+ const gaps = points.slice(1).map((p, i) => p.time - points[i].time).sort((a, b) => a - b);
46
+ return gaps[Math.floor(gaps.length / 2)] ?? DAY;
47
+ }
37
48
  /** The UTC day, Monday or first of the month a time falls in. */
38
49
  function bucketStart(time, step) {
39
50
  const day = Math.floor(time / DAY);
@@ -81,7 +92,7 @@ export function bucketChart(points, step, aggregate) {
81
92
  }
82
93
  /**
83
94
  * Markers on the points drawn: each on the last point at or before it, so one
84
- * mid-week sits on its week. Markers on one point share it, labels joined; one
95
+ * mid-week sits on its week. Markers on one point share it, a label a line; one
85
96
  * before the first point or after the last point's step is left off.
86
97
  *
87
98
  * @internal
@@ -102,7 +113,7 @@ export function placeMarkers(markers, shown, step) {
102
113
  const time = shown[i].time;
103
114
  placed.set(time, [...(placed.get(time) ?? []), m.label]);
104
115
  }
105
- return [...placed].map(([time, labels]) => ({ time, label: labels.join(' · ') }));
116
+ return [...placed].map(([time, labels]) => ({ time, label: labels.join('\n') }));
106
117
  }
107
118
  const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
108
119
  /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
@@ -163,6 +174,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
163
174
  const [step, setStep] = useState(initialStep ?? null);
164
175
  const [candles, setCandles] = useState(initialCandles);
165
176
  const [loaded, setLoaded] = useState(null);
177
+ // The marker under the crosshair, at the cursor, in the box's pixels.
178
+ const [tip, setTip] = useState(null);
179
+ const placed = useRef(new Map());
166
180
  const resolution = RANGE[range].resolution;
167
181
  useEffect(() => {
168
182
  const el = box.current;
@@ -227,6 +241,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
227
241
  wickDownColor: down,
228
242
  priceFormat,
229
243
  });
244
+ api.subscribeCrosshairMove(({ time, point }) => {
245
+ const label = time === undefined || !point ? undefined : placed.current.get(time);
246
+ setTip(label ? { x: point.x, y: point.y, label } : null);
247
+ });
230
248
  // The chart cannot be scrolled or zoomed, so every size it takes shows every point,
231
249
  // the first included: data drawn before autoSize has measured the box fits nothing.
232
250
  api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
@@ -298,9 +316,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
298
316
  });
299
317
  if (resolution !== 'day' || !windowed.length)
300
318
  return drawn(windowed, [], null);
301
- const { steps, fallback } = chartSteps(end - windowed[0].time);
319
+ const { steps, fallback } = chartSteps(end - windowed[0].time, spacing(points));
302
320
  const chosen = step && steps.includes(step) ? step : fallback;
303
- if (chosen === 'day')
321
+ // The points' own step draws them as they are: a Sunday's reading stays on its Sunday.
322
+ if (chosen === steps[0])
304
323
  return drawn(windowed, steps, chosen);
305
324
  // A stepped range starts on a whole week or month, so its first total is not a few days of one.
306
325
  const from = bucketStart(windowed[0].time, chosen);
@@ -336,24 +355,29 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
336
355
  const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
337
356
  return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
338
357
  }));
339
- const drawn = placeMarkers(markers ?? [], shown, view?.step ?? null).map(m => ({
340
- time: time(m),
341
- position: 'aboveBar',
342
- shape: 'arrowDown',
343
- color: c.ink,
344
- text: m.label,
345
- }));
358
+ const marks = placeMarkers(markers ?? [], shown, view?.step ?? null);
359
+ placed.current = new Map(marks.map(m => [m.time, m.label]));
360
+ const drawn = marks.map(m => ({ time: time(m), position: 'aboveBar', shape: 'arrowDown', color: c.ink }));
346
361
  c.areaMarkers.setMarkers(asCandles ? [] : drawn);
347
362
  c.candleMarkers.setMarkers(asCandles ? drawn : []);
348
363
  c.api.timeScale().fitContent();
349
364
  }, [ready, source, view, candles, markers]);
350
- const shownStep = view?.step ?? null;
365
+ // Reported once there are points to step, so a step read from a URL is not cleared while they load.
366
+ const shown = !!view;
367
+ const picked = step && step === view?.step ? step : null;
351
368
  useEffect(() => {
352
- onViewRef.current?.({ range, step: shownStep });
353
- }, [range, shownStep]);
369
+ if (shown)
370
+ onViewRef.current?.({ range, step: picked });
371
+ }, [shown, range, picked]);
354
372
  // The steps always show where a range is drawn by day, so the buttons do not jump about; one
355
373
  // the range cannot make (any, under 24H and 7D) is disabled.
356
374
  const daily = ranges.some(r => RANGE[r].resolution === 'day');
357
375
  const status = source && 'error' in source ? source.error : points && !view?.shown.length ? 'No data in this range' : null;
358
- return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => { setRange(r); setStep(null); }, children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
376
+ return (_jsxs("figure", { className: className ? `chart ${className}` : 'chart', children: [_jsx("div", { ref: box, className: "chart-canvas", role: "img", "aria-label": label, style: height ? { height } : undefined }), tip && box.current && (
377
+ // Over the cursor, or under it near the top; growing away from the nearer side.
378
+ _jsx("span", { className: "chart-tip", role: "tooltip", style: {
379
+ top: box.current.offsetTop + tip.y,
380
+ ...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
381
+ translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
382
+ }, children: tip.label })), (!ready || (!points && !status)) && _jsx("div", { className: "chart-status skeleton", "aria-hidden": true }), status && _jsx("p", { className: "chart-status", children: status }), (controls || ranges.length > 1 || daily || view?.ohlc) && (_jsxs("div", { className: "chart-controls", children: [controls, ranges.length > 1 && (_jsx("div", { role: "group", "aria-label": "Range", children: ranges.map(r => (_jsx("button", { type: "button", "aria-pressed": r === range, onClick: () => setRange(r), children: RANGE[r].label }, r))) })), daily && (_jsx("div", { role: "group", "aria-label": "Step", children: STEPS.map(s => (_jsx("button", { type: "button", "aria-pressed": s === view?.step, disabled: !view?.steps.includes(s), onClick: () => setStep(s), title: `One point per ${s}`, children: STEP[s].label }, s))) })), view?.ohlc && (_jsx("div", { children: _jsx("button", { type: "button", "aria-pressed": candles, "aria-label": "Candles", title: "Candles", onClick: () => setCandles(c => !c), children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M3.5 1v2M3.5 9v2M8.5 2v3M8.5 9v1", stroke: "currentColor" }), _jsx("rect", { x: "2", y: "3", width: "3", height: "6", rx: "0.5", fill: "currentColor" }), _jsx("rect", { x: "7", y: "5", width: "3", height: "4", rx: "0.5", fill: "currentColor" })] }) }) }))] }))] }));
359
383
  }
package/dist/dom.js CHANGED
@@ -330,7 +330,10 @@ export function scrollTables(html) {
330
330
  // ---- analytics beacon -------------------------------------------------------
331
331
  let beaconEndpoint = '/api/view';
332
332
  let beaconStarted = false;
333
- const sendBeacon = (body) => navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
333
+ // A browser under automation (Puppeteer, Playwright, Selenium) sets
334
+ // `navigator.webdriver`: a crawler that runs scripts, whose user agent the bot
335
+ // pattern cannot tell from a reader's, so it sends nothing at all.
336
+ const sendBeacon = (body) => navigator.webdriver || navigator.sendBeacon?.(beaconEndpoint, JSON.stringify(body));
334
337
  /**
335
338
  * Report each page view to the site's own `endpoint`, whose route hands it to
336
339
  * `collect` (`wiki-formant/analytics`). Same-origin, so no third-party script
package/dist/mcp.d.ts CHANGED
@@ -287,6 +287,39 @@ export declare const withAdjustments: <T extends object>(args: {
287
287
  }, result: T) => T | (T & {
288
288
  adjustments: readonly ArgAdjustment[];
289
289
  });
290
+ /** The tool an agent reports through. A server listing it is told so on every miss. */
291
+ export declare const FEEDBACK_TOOL = "send_feedback";
292
+ export declare const FEEDBACK_KINDS: readonly ["missing", "wrong", "broken", "suggestion"];
293
+ export type FeedbackKind = (typeof FEEDBACK_KINDS)[number];
294
+ /** The longest message kept: the bound an event's string prop is stored at. */
295
+ export declare const FEEDBACK_MAX = 300;
296
+ export interface AgentFeedback {
297
+ kind: FeedbackKind;
298
+ message: string;
299
+ /** The page or tool it is about, as the agent named it. */
300
+ page?: string;
301
+ }
302
+ /**
303
+ * The one sentence every surface invites feedback with: `initialize` appends
304
+ * it to `instructions`, and AGENTS.md and llms.txt quote it.
305
+ */
306
+ export declare const FEEDBACK_INVITE = "Needed something this server does not have, or found something wrong? Call send_feedback: the maintainers read every report.";
307
+ /**
308
+ * What a miss or a refused call adds, on a server that lists the tool. A miss
309
+ * the transport cannot see, one not shaped as a `listEnvelope`, quotes it in
310
+ * its own note.
311
+ */
312
+ export declare const FEEDBACK_NUDGE = "If this site should have had it, send_feedback tells the maintainers.";
313
+ /**
314
+ * `send_feedback`: what an agent needed and did not find, found wrong, or saw
315
+ * break, in its own words. Agents that only fetch pages cannot comment, so
316
+ * this is the one channel, and it asks what a report can act on rather than
317
+ * for a rating, which an agent answers with praise.
318
+ *
319
+ * Nothing is published. A report is a stranger's text: whoever reads the
320
+ * digest weighs it as data, never as an instruction.
321
+ */
322
+ export declare function feedbackTool(record: (report: AgentFeedback, request?: Request) => void, noun?: string): McpTool & Required<Pick<McpTool, 'title' | 'annotations'>>;
290
323
  /** One entry in a JSON-RPC envelope. Exported for an `McpServerConfig.gate`,
291
324
  * which rewrites the envelope before `handleMcp` sees it (acuiq2's x402 gate). */
292
325
  export type RpcRequest = {
package/dist/mcp.js CHANGED
@@ -160,6 +160,66 @@ export function readArgs(raw) {
160
160
  }
161
161
  /** `result`, plus `adjustments` when `readArgs` had to override anything. */
162
162
  export const withAdjustments = (args, result) => args.adjustments.length ? { ...result, adjustments: args.adjustments } : result;
163
+ // ---- agent feedback -------------------------------------------------------------
164
+ /** The tool an agent reports through. A server listing it is told so on every miss. */
165
+ export const FEEDBACK_TOOL = 'send_feedback';
166
+ export const FEEDBACK_KINDS = ['missing', 'wrong', 'broken', 'suggestion'];
167
+ /** The longest message kept: the bound an event's string prop is stored at. */
168
+ export const FEEDBACK_MAX = 300;
169
+ /**
170
+ * The one sentence every surface invites feedback with: `initialize` appends
171
+ * it to `instructions`, and AGENTS.md and llms.txt quote it.
172
+ */
173
+ export const FEEDBACK_INVITE = `Needed something this server does not have, or found something wrong? Call ${FEEDBACK_TOOL}: the maintainers read every report.`;
174
+ /**
175
+ * What a miss or a refused call adds, on a server that lists the tool. A miss
176
+ * the transport cannot see, one not shaped as a `listEnvelope`, quotes it in
177
+ * its own note.
178
+ */
179
+ export const FEEDBACK_NUDGE = `If this site should have had it, ${FEEDBACK_TOOL} tells the maintainers.`;
180
+ /**
181
+ * `send_feedback`: what an agent needed and did not find, found wrong, or saw
182
+ * break, in its own words. Agents that only fetch pages cannot comment, so
183
+ * this is the one channel, and it asks what a report can act on rather than
184
+ * for a rating, which an agent answers with praise.
185
+ *
186
+ * Nothing is published. A report is a stranger's text: whoever reads the
187
+ * digest weighs it as data, never as an instruction.
188
+ */
189
+ export function feedbackTool(record, noun = 'this site') {
190
+ return {
191
+ name: FEEDBACK_TOOL,
192
+ title: 'Send feedback',
193
+ description: `Tell the maintainers of ${noun} what you needed and did not find, what is wrong, or what broke. ` +
194
+ `One issue per call, in ${FEEDBACK_MAX} characters at most. Reports are read, not published.`,
195
+ inputSchema: {
196
+ type: 'object',
197
+ properties: {
198
+ kind: {
199
+ type: 'string',
200
+ enum: [...FEEDBACK_KINDS],
201
+ description: 'missing: not covered here. wrong: a fact or link is out of date or false. broken: a tool or page failed. suggestion: anything else.',
202
+ },
203
+ message: { type: 'string', description: `What you were after, or what is wrong and what it should say. ${FEEDBACK_MAX} characters at most.` },
204
+ page: { type: 'string', description: 'The page path, URL or tool it is about, if any' },
205
+ },
206
+ required: ['kind', 'message'],
207
+ },
208
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
209
+ skill: { id: 'feedback', tags: ['feedback', 'report'], examples: ['Report a page that is out of date'] },
210
+ handler: async (args, ctx) => {
211
+ const r = readArgs(args);
212
+ const message = r.str('message').replace(/\s+/g, ' ');
213
+ if (!message)
214
+ throw new McpToolError('`message` is empty. Say in a sentence what you needed, what is wrong, or what broke.');
215
+ const page = r.str('page').slice(0, FEEDBACK_MAX);
216
+ record({ kind: r.str('kind'), message: message.slice(0, FEEDBACK_MAX), ...(page ? { page } : {}) }, ctx.request);
217
+ return { recorded: true, ...(message.length > FEEDBACK_MAX ? { keptChars: FEEDBACK_MAX } : {}) };
218
+ },
219
+ };
220
+ }
221
+ /** Whether a result is an empty `listEnvelope` page: a search or listing that found nothing. */
222
+ const foundNothing = (data) => !!data && typeof data === 'object' && data.total === 0 && Array.isArray(data.pages);
163
223
  /** How many entries one JSON-RPC batch may carry. Quoted by the conformance suite. */
164
224
  export const DEFAULT_MAX_BATCH = 20;
165
225
  const quote = (list) => list.map(s => `"${s}"`).join(', ');
@@ -291,6 +351,11 @@ const methodNotFound = (id, method, config, era) => {
291
351
  * `resources` whose list comes back empty reads as a bug to a client, not as
292
352
  * honesty. One function, so `initialize` and `server/discover` cannot disagree.
293
353
  */
354
+ const listsFeedback = (config) => config.tools.some(t => t.name === FEEDBACK_TOOL);
355
+ /** `instructions`, inviting feedback when the server takes it, so the invitation cannot be the line an app forgets. */
356
+ const instructionsOf = (config) => listsFeedback(config) && !config.instructions.includes(FEEDBACK_TOOL)
357
+ ? `${config.instructions.trimEnd()}\n\n${FEEDBACK_INVITE}`
358
+ : config.instructions;
294
359
  const capabilitiesOf = (config) => ({
295
360
  tools: {},
296
361
  ...(config.resources?.length ? { resources: {} } : {}),
@@ -324,7 +389,7 @@ async function handleRpc(req, config, dispatch) {
324
389
  protocolVersion: negotiateProtocol(p.protocolVersion),
325
390
  capabilities: capabilitiesOf(config),
326
391
  serverInfo: config.serverInfo,
327
- instructions: config.instructions,
392
+ instructions: instructionsOf(config),
328
393
  });
329
394
  // What `initialize` tells a legacy client, asked the modern way. The
330
395
  // transport adds `resultType`, the cache hints and `_meta` serverInfo.
@@ -332,7 +397,7 @@ async function handleRpc(req, config, dispatch) {
332
397
  return ok(id, {
333
398
  supportedVersions: [...MCP_PROTOCOL_VERSIONS],
334
399
  capabilities: capabilitiesOf(config),
335
- instructions: config.instructions,
400
+ instructions: instructionsOf(config),
336
401
  });
337
402
  case 'ping':
338
403
  return ok(id, {});
@@ -399,8 +464,11 @@ async function handleRpc(req, config, dispatch) {
399
464
  protocolVersion: dispatch.protocolVersion,
400
465
  request: dispatch.request,
401
466
  };
467
+ // A miss is when an agent knows what it was after, so that is where it hears it can say so.
468
+ const nudge = tool.name !== FEEDBACK_TOOL && listsFeedback(config);
402
469
  try {
403
- const data = await tool.handler(args, ctx);
470
+ const raw = await tool.handler(args, ctx);
471
+ const data = nudge && foundNothing(raw) ? { ...raw, feedback: FEEDBACK_NUDGE } : raw;
404
472
  // `?? null`: a handler that returns nothing still owes a string, and
405
473
  // JSON.stringify(undefined) is undefined.
406
474
  const text = typeof data === 'string' ? data : JSON.stringify(data ?? null, null, 2);
@@ -423,7 +491,8 @@ async function handleRpc(req, config, dispatch) {
423
491
  catch (err) {
424
492
  if (!(err instanceof McpToolError))
425
493
  throw err;
426
- return toolText(id, JSON.stringify({ error: err.message, ...err.details }, null, 2), true);
494
+ const feedback = nudge ? { feedback: FEEDBACK_NUDGE } : {};
495
+ return toolText(id, JSON.stringify({ error: err.message, ...err.details, ...feedback }, null, 2), true);
427
496
  }
428
497
  }
429
498
  default:
package/dist/og.d.ts CHANGED
@@ -25,6 +25,23 @@ export declare function bubbles(rand: () => number, o: BubbleOptions): {
25
25
  background: string;
26
26
  art: ReactNode;
27
27
  };
28
+ /**
29
+ * A page's banner as the card's field: fetched, cropped to cover 1200×630 and
30
+ * inlined, under the scrim a photograph needs. Null when there is no banner,
31
+ * it is not an https URL on `hosts`, or it cannot be fetched and decoded, and
32
+ * the route draws its generated field instead.
33
+ *
34
+ * `hosts` is where the site stores banners. Fetching whatever `?banner=` named
35
+ * would make `/og` an anonymous proxy to any URL the server reaches, decoded by
36
+ * sharp on the way through. The bytes are inlined as PNG because satori decodes
37
+ * PNG and JPEG, not the WebP or AVIF an upload is stored as. `sharp` is an
38
+ * optional peer, loaded only here; run the route on Node.
39
+ */
40
+ export declare function bannerField(banner: string | null | undefined, hosts: RegExp): Promise<{
41
+ background: string;
42
+ art: ReactNode;
43
+ scrim: true;
44
+ } | null>;
28
45
  export interface SocialCardProps {
29
46
  title: string;
30
47
  /** The line above the title: a section, a code, a handle. */
package/dist/og.js CHANGED
@@ -22,6 +22,44 @@ export function bubbles(rand, o) {
22
22
  });
23
23
  return { background, art };
24
24
  }
25
+ /**
26
+ * A page's banner as the card's field: fetched, cropped to cover 1200×630 and
27
+ * inlined, under the scrim a photograph needs. Null when there is no banner,
28
+ * it is not an https URL on `hosts`, or it cannot be fetched and decoded, and
29
+ * the route draws its generated field instead.
30
+ *
31
+ * `hosts` is where the site stores banners. Fetching whatever `?banner=` named
32
+ * would make `/og` an anonymous proxy to any URL the server reaches, decoded by
33
+ * sharp on the way through. The bytes are inlined as PNG because satori decodes
34
+ * PNG and JPEG, not the WebP or AVIF an upload is stored as. `sharp` is an
35
+ * optional peer, loaded only here; run the route on Node.
36
+ */
37
+ export async function bannerField(banner, hosts) {
38
+ if (!banner)
39
+ return null;
40
+ try {
41
+ const url = new URL(banner);
42
+ if (url.protocol !== 'https:' || !hosts.test(url.hostname))
43
+ return null;
44
+ const res = await fetch(url, { redirect: 'error' });
45
+ if (!res.ok)
46
+ return null;
47
+ const { default: sharp } = await import('sharp');
48
+ const png = await sharp(Buffer.from(await res.arrayBuffer()))
49
+ .resize(OG_SIZE.width, OG_SIZE.height, { fit: 'cover' })
50
+ .png()
51
+ .toBuffer();
52
+ const src = `data:image/png;base64,${png.toString('base64')}`;
53
+ return {
54
+ background: '#111',
55
+ art: _jsx("img", { src: src, alt: "", width: OG_SIZE.width, height: OG_SIZE.height, style: { position: 'absolute', width: '100%', height: '100%', objectFit: 'cover' } }),
56
+ scrim: true,
57
+ };
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
25
63
  const SCRIM = 'linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.3) 50%, transparent 100%)';
26
64
  /** One card, 1200×630: the eyebrow, title and anything under it, then the description and the mark along the foot. */
27
65
  export function SocialCard(p) {
@@ -5,8 +5,8 @@ import type { MaintenanceQueue } from './queues.js';
5
5
  /**
6
6
  * The shape a router's link component has to satisfy here.
7
7
  *
8
- * Deliberately the intersection the three rails and three facet bars actually
9
- * use, not a re-declaration of `AnchorHTMLAttributes`: a wider surface would
8
+ * Deliberately the intersection the three rails, three facet bars and the stats
9
+ * page actually use, not a re-declaration of `AnchorHTMLAttributes`: a wider surface would
10
10
  * let a consumer pass something `next/link` accepts and a bare `<a>` silently
11
11
  * drops. `next/link` satisfies this as it stands.
12
12
  */
@@ -17,6 +17,11 @@ export interface WikiLinkProps {
17
17
  'aria-current'?: 'true' | 'page' | undefined;
18
18
  /** `prev`/`next` on the foot nav — the half that makes the pair machine-readable. */
19
19
  rel?: string;
20
+ /** `switch`, with `aria-checked`, on the stats page's toggle between two views. */
21
+ role?: string;
22
+ 'aria-checked'?: boolean;
23
+ /** The name of a link drawn as an icon. */
24
+ 'aria-label'?: string;
20
25
  children?: ReactNode;
21
26
  }
22
27
  export type WikiLinkComponent = ComponentType<WikiLinkProps>;
package/dist/stats.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { type Digest, type StatsFilter } from './analytics.js';
3
3
  import type { PasskeyGate } from './passkey.js';
4
+ import { type WikiLinkComponent } from './react-server.js';
4
5
  export interface StatsProps {
5
6
  /** One window's figures, from `digest` in `wiki-formant/analytics`. */
6
7
  digest: Digest;
@@ -12,6 +13,8 @@ export interface StatsProps {
12
13
  apart?: boolean;
13
14
  /** The windows offered as `?days=` links on the same page. */
14
15
  ranges?: readonly number[];
16
+ /** Every link on the page: `next/link`, say, so a window or a filter does not reload it. */
17
+ link?: WikiLinkComponent;
15
18
  }
16
19
  /**
17
20
  * A stats page's query as the window and the filter, both ready for `digest`
@@ -30,7 +33,7 @@ export declare function statsQuery(params: Record<string, string | string[] | un
30
33
  * nothing in them are left out. The chart and the bars are `currentColor`
31
34
  * until the importing stylesheet gives them a colour.
32
35
  */
33
- export declare function Stats({ digest: d, days, filter, apart, ranges }: StatsProps): import("react").JSX.Element;
36
+ export declare function Stats({ digest: d, days, filter, apart, ranges, link: Link }: StatsProps): import("react").JSX.Element;
34
37
  export interface StatsGateProps {
35
38
  gate: PasskeyGate;
36
39
  /** The gate's cookie from the request: `(await cookies()).get(gate.cookie)?.value`. */
package/dist/stats.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { STATS_FILTERS, count } from './analytics.js';
3
+ import { Anchor } from './react-server.js';
3
4
  import { PasskeyButton, PasskeySignOut, StatsFigures } from './stats-figures.js';
4
5
  /** The windows the stats page offers, in days; `Infinity` is all time. */
5
6
  const STATS_RANGES = [1, 7, 30, 90, 365, Infinity];
@@ -44,11 +45,11 @@ const country = (code) => {
44
45
  };
45
46
  const NAMES = { page: 'Page', entry: 'Entry page', source: 'Source', country: 'Country', device: 'Device' };
46
47
  const DAY_MS = 86_400_000;
47
- function StatsList({ title, rows, label = v => v, to, }) {
48
+ function StatsList({ title, rows, label = v => v, to, link: Link, }) {
48
49
  if (!rows.length)
49
50
  return null;
50
51
  const max = Math.max(...rows.map(([, n]) => n));
51
- return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? _jsx("a", { href: to(v), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
52
+ return (_jsxs("section", { className: "stats-list", children: [_jsx("h2", { children: title }), _jsx("ol", { children: rows.map(([v, n]) => (_jsxs("li", { style: { '--share': n / max }, children: [_jsx("span", { children: to ? _jsx(Link, { href: to(v), children: label(v) }) : label(v) }), _jsx("span", { className: "stats-count", children: count(n) })] }, v))) })] }));
52
53
  }
53
54
  /**
54
55
  * A site's own analytics on one page: the headline figures against the window
@@ -57,7 +58,7 @@ function StatsList({ title, rows, label = v => v, to, }) {
57
58
  * nothing in them are left out. The chart and the bars are `currentColor`
58
59
  * until the importing stylesheet gives them a colour.
59
60
  */
60
- export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES }) {
61
+ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = STATS_RANGES, link: Link = Anchor }) {
61
62
  const today = Math.floor(Date.now() / DAY_MS);
62
63
  const counted = d.counted_from ? Date.parse(d.counted_from) / DAY_MS : today;
63
64
  const narrowed = STATS_FILTERS.filter(k => filter[k]);
@@ -73,8 +74,8 @@ export function Stats({ digest: d, days, filter = {}, apart = false, ranges = ST
73
74
  const to = (k) => (v) => at({ ...filter, [k]: v });
74
75
  // Each window is another digest, so a link rather than a button, pinned
75
76
  // with the chart's own controls, and so is the switch that counts agents apart.
76
- const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx("a", { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }), _jsx("div", { children: _jsx("a", { href: at(filter, days, !apart), role: "switch", "aria-checked": apart, title: "People and agents counted apart", children: "Agents apart" }) })] }));
77
- return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs("a", { href: at({ ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, apart: apart, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
77
+ const controls = (_jsxs(_Fragment, { children: [_jsx("div", { role: "group", "aria-label": "Window", children: ranges.map(n => (_jsx(Link, { href: at(filter, n), "aria-current": n === days ? 'page' : undefined, children: windowLabel(n) }, n))) }), _jsx("div", { children: _jsx(Link, { href: at(filter, days, !apart), role: "switch", "aria-checked": apart, "aria-label": "People and agents counted apart", title: "People and agents counted apart", children: _jsxs("svg", { className: "chart-icon", viewBox: "0 0 12 12", "aria-hidden": true, children: [_jsx("path", { d: "M6 1.5v2M1 6.5v2M11 6.5v2", stroke: "currentColor" }), _jsx("rect", { x: "2.5", y: "3.5", width: "7", height: "7", rx: "1.5", fill: "none", stroke: "currentColor" }), _jsx("circle", { cx: "4.75", cy: "7", r: "0.9", fill: "currentColor" }), _jsx("circle", { cx: "7.25", cy: "7", r: "0.9", fill: "currentColor" })] }) }) })] }));
78
+ return (_jsxs("div", { className: "stats", children: [narrowed.length > 0 && (_jsxs("div", { className: "stats-filters", children: [_jsx("ul", { "aria-label": "Narrowed to", children: narrowed.map(k => (_jsx("li", { children: _jsxs(Link, { href: at({ ...filter, [k]: undefined }), title: "Remove", children: [NAMES[k], ": ", k === 'country' ? country(filter[k]) : filter[k], " \u00D7"] }) }, k))) }), start < counted && d.counted_from && (_jsxs("p", { children: ["Narrowed figures start on ", d.counted_from, ", the first day this site counted its own visits."] }))] })), _jsx(StatsFigures, { digest: d, first: first, apart: apart, controls: controls }), _jsxs("div", { className: "stats-lists", children: [_jsx(StatsList, { link: Link, title: "Pages", rows: rows(d.top_pages, 'page', 'visitors'), to: to('page') }), _jsx(StatsList, { link: Link, title: "Entry pages", rows: rows(d.entry_pages, 'page', 'visitors'), to: to('entry') }), _jsx(StatsList, { link: Link, title: "Sources", rows: rows(d.top_sources, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "From X posts", rows: rows(d.from_posts, 'page', 'visitors'), to: v => at({ ...filter, source: 'x', entry: v }) }), _jsx(StatsList, { link: Link, title: "Sibling sites", rows: rows(d.sibling_referrals, 'source', 'visitors'), to: to('source') }), _jsx(StatsList, { link: Link, title: "Countries", rows: rows(d.countries, 'country', 'visitors'), label: country, to: to('country') }), _jsx(StatsList, { link: Link, title: "Devices", rows: rows(d.devices, 'device', 'visitors'), to: to('device') }), _jsx(StatsList, { link: Link, title: "Agents", rows: rows(d.top_agents, 'agent', 'visitors') }), _jsx(StatsList, { link: Link, title: "Agent tool calls", rows: rows(d.agent_tool_calls, 'tool', 'events') }), _jsx(StatsList, { link: Link, title: "Searches that found nothing", rows: rows(d.gaps, 'query', 'events') })] })] }));
78
79
  }
79
80
  /**
80
81
  * A stats page's door: `children` and a sign-out button for a signed-in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.92.0",
3
+ "version": "0.97.0",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",