wiki-formant 0.91.1 → 0.94.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
@@ -859,7 +859,7 @@ export const GET = resolveMapHandler({ authorize: async () => !!(await currentUs
859
859
 
860
860
  ## Block editor
861
861
 
862
- `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once, and so is the chrome: each block is a card with a toolbar (its name, then add below, move, duplicate and delete, shown while the block is in hand and always on a touch screen), and every add menu is two tight columns of names, each type's `hint` its tooltip, drawn by `base.css` on the `--ui-*` tokens. The fields' look arrives as a kit.
862
+ `wiki-formant/block-editor` is the block list an author edits and the fields of the core types. Three wikis had written it, 500 to 700 lines each, and had drifted: one dropped a stored code language its list did not name, one let a typed count run past the row cap, one named every remove button "Remove", one never marked the block being edited. The behaviour is here once, and so is the chrome: each block is a card with a toolbar (its name, then add below, move, duplicate and delete, shown while the block is in hand and always on a touch screen), and every add menu is two tight columns of names, each type's `hint` its tooltip, drawn by `base.css` on the `--ui-*` tokens. An option's `group` lists it under that heading, groups in the order they first appear; a menu of more than eight opens on a box that finds a type by name, Enter taking the first found (on a touch screen it waits to be tapped, so the keyboard does not cover the sheet). The fields' look arrives as a kit.
863
863
 
864
864
  ```tsx
865
865
  const KIT: BlockEditorKit = {
@@ -957,7 +957,10 @@ An agent is counted as a visitor is, one address and user agent a day, from its
957
957
  `AI Bot Visit` and `MCP Call` events; it runs no page script, so it is never
958
958
  among the visitors, and `visitors_incl_agents` counts the two at once.
959
959
  `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`
960
+ visitor's views with no gap over 30 minutes, and a bounce is a visit of one view
961
+ with no event of the reader's inside it: a search that keeps the path, a click
962
+ out or a share makes the visit count. A browser under automation
963
+ (`navigator.webdriver`) sends no beacon at all. A multi-tenant app passes a `site`
961
964
  key to every call and a `site` resolver to `collect`.
962
965
 
963
966
  Instrumenting the agent lane and not the human one is the easy mistake: it
@@ -1081,7 +1084,8 @@ which no total makes sense of. A stepped range starts on a whole week or month,
1081
1084
  so its first point is not a few days of one; the week in progress is its total
1082
1085
  so far. `fromZero` starts a count's axis at zero and stops it there, with
1083
1086
  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.
1087
+ No step is finer than the points' own spacing, so weekly readings offer W and M
1088
+ and draw on the days they were taken. `chartSteps` is exported for a chart drawn elsewhere.
1085
1089
 
1086
1090
  `format` writes the axis and the crosshair label. An axis tick also gets the
1087
1091
  spacing between ticks as a second argument, so a formatter can write every tick
@@ -1090,13 +1094,15 @@ decimal places that takes, and `formatChartValue`, the default, uses it.
1090
1094
 
1091
1095
  `markers` are events, `{ time, label }`, each drawn as an arrow on the last
1092
1096
  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:
1097
+ share it. No words go on the plot: the label is a tooltip (`.chart-tip`) while
1098
+ the crosshair is on the point, one line an event, so it can be the event's full
1099
+ name. `step` opens on a step where the range can make it, and `onView` hears the
1100
+ range, and the step the reader picked (null while the chart picks its own), once
1101
+ the points are in and on every change, for a page that keeps them in its URL:
1096
1102
 
1097
1103
  ```tsx
1098
1104
  <TimeChart series={days} label="Transactions" aggregate="sum" fromZero
1099
- markers={[{ time: haltAt, label: 'Halt' }]} step="month" onView={v => keep(v)} />
1105
+ markers={[{ time: haltAt, label: 'Network halt' }]} step="month" onView={v => keep(v)} />
1100
1106
  ```
1101
1107
  `base.css` pins the buttons (`.chart-controls button[aria-pressed]`) to the
1102
1108
  canvas's top-left corner, rules a line between groups and dims a disabled step;
@@ -89,7 +89,7 @@ export interface DigestDay {
89
89
  visitors: number;
90
90
  pageviews: number;
91
91
  visits: number;
92
- /** Visits that saw one page. */
92
+ /** Visits that saw one page and did nothing else: a search, a click out or any reader event makes it a visit. */
93
93
  bounces: number;
94
94
  /** Seconds from each visit's first view to its last, summed. */
95
95
  seconds: number;
@@ -101,7 +101,7 @@ export interface DigestDay {
101
101
  export interface Digest {
102
102
  visitors: number;
103
103
  pageviews: number;
104
- /** Percent of visits that saw one page; null with no visits. */
104
+ /** Percent of visits that are bounces (`DigestDay.bounces`); null with no visits. */
105
105
  bounce_rate: number | null;
106
106
  /** Mean seconds from a visit's first view to its last; null with no visits. */
107
107
  visit_duration: number | null;
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` : ''}
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;
@@ -722,7 +736,8 @@ pre:hover > .code-copy-btn,
722
736
  /* ---- the block editor (BlockEditor, BlockToolbar, AddBlockMenu) -------------
723
737
  Every wiki's editor wears the same chrome: a card per block, its name and its
724
738
  controls in a toolbar above its fields, and an add menu of two tight columns,
725
- names only, with what each type is for as its tooltip. The four had drawn it
739
+ names only, with what each type is for as its tooltip, under their groups'
740
+ headings, and a box to find one by name at the top of a long menu. The four had drawn it
726
741
  four ways, one a 16-row list a screen tall. A block's controls show while it
727
742
  is in hand (hovered, focused, picked, or with its menu open), and always on a
728
743
  screen with no hover. The fields inside are the site's kit. */
@@ -800,9 +815,12 @@ pre:hover > .code-copy-btn,
800
815
  position: absolute;
801
816
  top: calc(100% + 0.25rem);
802
817
  z-index: 50;
803
- display: grid;
804
- grid-template-columns: repeat(2, minmax(0, 1fr));
818
+ display: flex;
819
+ flex-direction: column;
805
820
  width: min(24rem, calc(100vw - 2rem));
821
+ max-height: min(28rem, calc(100dvh - 2rem));
822
+ overflow-y: auto;
823
+ overscroll-behavior: contain;
806
824
  padding: 0.25rem;
807
825
  background: var(--ui-surface);
808
826
  border: 1px solid var(--ui-border);
@@ -818,6 +836,35 @@ pre:hover > .code-copy-btn,
818
836
  /* As specific as the two placements above, and after them. */
819
837
  .block-menu:is(:not([data-below]), [data-below]) > .block-menu__panel { position: fixed; inset: auto 1rem 1rem; width: auto; translate: none; }
820
838
  }
839
+ .block-menu__group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
840
+ .block-menu__group + .block-menu__group { margin-top: 0.25rem; }
841
+ .block-menu__heading {
842
+ grid-column: 1 / -1;
843
+ padding: 0.375rem 0.5rem 0.125rem;
844
+ font-size: var(--ui-small);
845
+ font-weight: 500;
846
+ letter-spacing: 0.02em;
847
+ text-transform: uppercase;
848
+ color: var(--ui-muted);
849
+ }
850
+ /* The box stays in view as a long menu scrolls under it. */
851
+ .block-menu__find {
852
+ position: sticky;
853
+ top: -0.25rem;
854
+ z-index: 1;
855
+ margin: -0.25rem -0.25rem 0.25rem;
856
+ padding: 0.5rem 0.75rem;
857
+ border: 0;
858
+ border-bottom: 1px solid var(--ui-border);
859
+ border-radius: var(--ui-radius) var(--ui-radius) 0 0;
860
+ background: var(--ui-surface);
861
+ font: inherit;
862
+ font-size: var(--ui-small);
863
+ color: inherit;
864
+ outline: none;
865
+ }
866
+ .block-menu__find:focus-visible { border-bottom-color: var(--ui-accent); }
867
+ .block-menu__none { padding: 0.375rem 0.5rem; font-size: var(--ui-small); color: var(--ui-muted); }
821
868
  .block-menu__item {
822
869
  display: flex;
823
870
  align-items: center;
@@ -117,6 +117,8 @@ export interface BlockOption {
117
117
  icon?: ReactNode;
118
118
  /** What it is for: the item's tooltip. */
119
119
  hint?: string;
120
+ /** The heading it is listed under; groups keep the order they first appear in. */
121
+ group?: string;
120
122
  }
121
123
  export interface AddBlockMenuProps {
122
124
  options: readonly BlockOption[];
@@ -127,7 +129,11 @@ export interface AddBlockMenuProps {
127
129
  /** The foot button's words. */
128
130
  label?: string;
129
131
  }
130
- /** An add button and its menu, which closes on a pick, a click outside it or Escape. Nothing to offer, no button. */
132
+ /**
133
+ * An add button and its menu, which closes on a pick, a click outside it or
134
+ * Escape. A long menu opens on a box that finds a type by name, Enter taking
135
+ * the first found. Nothing to offer, no button.
136
+ */
131
137
  export declare function AddBlockMenu({ options, onPick, icon, below, label }: AddBlockMenuProps): import("react").JSX.Element | null;
132
138
  export interface BlockToolbarProps extends Pick<BlockActionsProps, 'index' | 'total' | 'ops'> {
133
139
  label: string;
@@ -22,7 +22,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
22
22
  // what lets a column or an infobox hold a list without being handed the whole
23
23
  // configuration. Keep both stable — a module constant or a `useMemo` — or every
24
24
  // card re-renders on every keystroke.
25
- import { createContext, memo, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, } from 'react';
25
+ import { createContext, memo, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react';
26
26
  import { BlockActions, useBlockOperations, useClickOutside } from './react.js';
27
27
  import { CODE_LANGS, DEFAULT_LANG, EVENT_DATE, eventBegun, eventEnded, eventWhen, LIST_SOURCES, today, } from './blocks.js';
28
28
  import { BANNER_VARIANTS } from './text.js';
@@ -93,13 +93,40 @@ const BlockCard = memo(function BlockCard({ block, index, total, selected, neste
93
93
  });
94
94
  /** The least gap, in px, between an open menu and the window's side. */
95
95
  const MENU_MARGIN = 8;
96
- /** An add button and its menu, which closes on a pick, a click outside it or Escape. Nothing to offer, no button. */
96
+ /** A menu longer than this opens with a box to find a type by name. */
97
+ const FIND_OVER = 8;
98
+ /** Options under their headings, in the order each group first appears. */
99
+ function grouped(options) {
100
+ const groups = new Map();
101
+ for (const o of options) {
102
+ const key = o.group ?? '';
103
+ groups.set(key, [...(groups.get(key) ?? []), o]);
104
+ }
105
+ return [...groups];
106
+ }
107
+ /**
108
+ * An add button and its menu, which closes on a pick, a click outside it or
109
+ * Escape. A long menu opens on a box that finds a type by name, Enter taking
110
+ * the first found. Nothing to offer, no button.
111
+ */
97
112
  export function AddBlockMenu({ options, onPick, icon, below = false, label = 'Add block' }) {
98
113
  const [open, setOpen] = useState(false);
99
- const close = useCallback(() => setOpen(false), []);
114
+ const [query, setQuery] = useState('');
115
+ const close = useCallback(() => {
116
+ setOpen(false);
117
+ setQuery('');
118
+ }, []);
100
119
  const ref = useClickOutside(close);
101
120
  const trigger = useRef(null);
102
121
  const panel = useRef(null);
122
+ const find = useRef(null);
123
+ // A touch screen is left without its keyboard until the box is tapped, so
124
+ // the sheet is not half hidden behind it.
125
+ useEffect(() => {
126
+ const el = find.current;
127
+ if (open && el && !el.ownerDocument.defaultView?.matchMedia?.('(hover: none)').matches)
128
+ el.focus();
129
+ }, [open]);
103
130
  // Hung off a button deep in a column, the panel can run past the window's
104
131
  // edge: it moves back in by what it overhangs. A phone's sheet is fixed and
105
132
  // already fits.
@@ -119,18 +146,28 @@ export function AddBlockMenu({ options, onPick, icon, below = false, label = 'Ad
119
146
  }, [open]);
120
147
  if (options.length === 0)
121
148
  return null;
149
+ const words = query.trim().toLowerCase();
150
+ const found = words ? options.filter(o => o.label.toLowerCase().includes(words)) : options;
151
+ const pick = (type) => {
152
+ close();
153
+ onPick(type);
154
+ };
122
155
  return (_jsxs("span", { ref: ref, className: "block-menu", "data-below": below || undefined, onKeyDown: e => {
123
156
  if (e.key !== 'Escape' || !open)
124
157
  return;
125
158
  e.stopPropagation();
126
159
  close();
127
160
  trigger.current?.focus();
128
- }, children: [_jsxs("button", { ref: trigger, type: "button", className: below ? 'block-toolbar__button' : 'block-add', title: below ? 'Add a block below' : undefined, "aria-label": below ? 'Add a block below' : undefined, "aria-haspopup": "menu", "aria-expanded": open, onClick: () => setOpen(o => !o), children: [icon, !below && label] }), open && (_jsx("span", { ref: panel, className: "block-menu__panel", role: "menu", children: options.map(o => (_jsxs("button", { type: "button", role: "menuitem", className: "block-menu__item", title: o.hint, onClick: e => {
129
- // Inside a card, a pick is not also a click on the card.
130
- e.stopPropagation();
131
- close();
132
- onPick(o.type);
133
- }, children: [o.icon, _jsx("span", { children: o.label })] }, o.type))) }))] }));
161
+ }, children: [_jsxs("button", { ref: trigger, type: "button", className: below ? 'block-toolbar__button' : 'block-add', title: below ? 'Add a block below' : undefined, "aria-label": below ? 'Add a block below' : undefined, "aria-haspopup": "menu", "aria-expanded": open, onClick: () => (open ? close() : setOpen(true)), children: [icon, !below && label] }), open && (_jsxs("span", { ref: panel, className: "block-menu__panel", role: "menu", children: [options.length > FIND_OVER && (_jsx("input", { ref: find, type: "search", className: "block-menu__find", placeholder: "Find a block", "aria-label": "Find a block", enterKeyHint: "go", value: query, onChange: e => setQuery(e.target.value), onKeyDown: e => {
162
+ if (e.key !== 'Enter' || !found[0])
163
+ return;
164
+ e.preventDefault();
165
+ pick(found[0].type);
166
+ } })), found.length === 0 && _jsx("span", { className: "block-menu__none", children: "No block by that name." }), grouped(found).map(([group, items]) => (_jsxs("span", { className: "block-menu__group", role: "group", "aria-label": group || undefined, children: [group && (_jsx("span", { className: "block-menu__heading", "aria-hidden": true, children: group })), items.map(o => (_jsxs("button", { type: "button", role: "menuitem", className: "block-menu__item", title: o.hint, onClick: e => {
167
+ // Inside a card, a pick is not also a click on the card.
168
+ e.stopPropagation();
169
+ pick(o.type);
170
+ }, children: [o.icon, _jsx("span", { children: o.label })] }, o.type)))] }, group)))] }))] }));
134
171
  }
135
172
  /** A block's name and its controls: add below, move up and down, duplicate, delete. */
136
173
  export function BlockToolbar({ label, icon, index, total, ops, icons, options, onAdd, children }) {
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
+ * A step is offered once it makes four points, and the default is the finest that
37
+ * makes 200 or fewer. None is finer than the points' own `spacing`: weekly readings
38
+ * 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,22 @@ 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
+ * A step is offered once it makes four points, and the default is the finest that
35
+ * makes 200 or fewer. None is finer than the points' own `spacing`: weekly readings
36
+ * 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 finest = fine[0] ?? 'month';
41
+ const steps = STEPS.filter(s => s === finest || (fine.includes(s) && span / STEP[s].span >= 4));
35
42
  return { steps, fallback: steps.find(s => span / STEP[s].span <= 200) ?? steps.at(-1) };
36
43
  }
44
+ /** The points' own spacing: the median gap, so a missing day or a skipped week does not change it. */
45
+ function spacing(points) {
46
+ const gaps = points.slice(1).map((p, i) => p.time - points[i].time).sort((a, b) => a - b);
47
+ return gaps[Math.floor(gaps.length / 2)] ?? DAY;
48
+ }
37
49
  /** The UTC day, Monday or first of the month a time falls in. */
38
50
  function bucketStart(time, step) {
39
51
  const day = Math.floor(time / DAY);
@@ -81,7 +93,7 @@ export function bucketChart(points, step, aggregate) {
81
93
  }
82
94
  /**
83
95
  * 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
96
+ * mid-week sits on its week. Markers on one point share it, a label a line; one
85
97
  * before the first point or after the last point's step is left off.
86
98
  *
87
99
  * @internal
@@ -102,7 +114,7 @@ export function placeMarkers(markers, shown, step) {
102
114
  const time = shown[i].time;
103
115
  placed.set(time, [...(placed.get(time) ?? []), m.label]);
104
116
  }
105
- return [...placed].map(([time, labels]) => ({ time, label: labels.join(' · ') }));
117
+ return [...placed].map(([time, labels]) => ({ time, label: labels.join('\n') }));
106
118
  }
107
119
  const isCandle = (p) => p.open !== undefined && p.high !== undefined && p.low !== undefined;
108
120
  /** The decimal places that write every multiple of `step` exactly: two for 0.25, none for 5000. */
@@ -163,6 +175,9 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
163
175
  const [step, setStep] = useState(initialStep ?? null);
164
176
  const [candles, setCandles] = useState(initialCandles);
165
177
  const [loaded, setLoaded] = useState(null);
178
+ // The marker under the crosshair, at the cursor, in the box's pixels.
179
+ const [tip, setTip] = useState(null);
180
+ const placed = useRef(new Map());
166
181
  const resolution = RANGE[range].resolution;
167
182
  useEffect(() => {
168
183
  const el = box.current;
@@ -227,6 +242,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
227
242
  wickDownColor: down,
228
243
  priceFormat,
229
244
  });
245
+ api.subscribeCrosshairMove(({ time, point }) => {
246
+ const label = time === undefined || !point ? undefined : placed.current.get(time);
247
+ setTip(label ? { x: point.x, y: point.y, label } : null);
248
+ });
230
249
  // The chart cannot be scrolled or zoomed, so every size it takes shows every point,
231
250
  // the first included: data drawn before autoSize has measured the box fits nothing.
232
251
  api.timeScale().subscribeSizeChange(() => api.timeScale().fitContent());
@@ -298,9 +317,10 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
298
317
  });
299
318
  if (resolution !== 'day' || !windowed.length)
300
319
  return drawn(windowed, [], null);
301
- const { steps, fallback } = chartSteps(end - windowed[0].time);
320
+ const { steps, fallback } = chartSteps(end - windowed[0].time, spacing(points));
302
321
  const chosen = step && steps.includes(step) ? step : fallback;
303
- if (chosen === 'day')
322
+ // The points' own step draws them as they are: a Sunday's reading stays on its Sunday.
323
+ if (chosen === steps[0])
304
324
  return drawn(windowed, steps, chosen);
305
325
  // A stepped range starts on a whole week or month, so its first total is not a few days of one.
306
326
  const from = bucketStart(windowed[0].time, chosen);
@@ -336,24 +356,29 @@ export function TimeChart({ series, label, ranges = typeof series === 'function'
336
356
  const rising = p.value >= (p.open ?? shown[i - 1]?.value ?? p.value);
337
357
  return { time: time(p), value: p.volume ?? 0, color: (rising ? c.up : c.down).replace(/[\d.]+\)$/, '0.5)') };
338
358
  }));
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
- }));
359
+ const marks = placeMarkers(markers ?? [], shown, view?.step ?? null);
360
+ placed.current = new Map(marks.map(m => [m.time, m.label]));
361
+ const drawn = marks.map(m => ({ time: time(m), position: 'aboveBar', shape: 'arrowDown', color: c.ink }));
346
362
  c.areaMarkers.setMarkers(asCandles ? [] : drawn);
347
363
  c.candleMarkers.setMarkers(asCandles ? drawn : []);
348
364
  c.api.timeScale().fitContent();
349
365
  }, [ready, source, view, candles, markers]);
350
- const shownStep = view?.step ?? null;
366
+ // Reported once there are points to step, so a step read from a URL is not cleared while they load.
367
+ const shown = !!view;
368
+ const picked = step && step === view?.step ? step : null;
351
369
  useEffect(() => {
352
- onViewRef.current?.({ range, step: shownStep });
353
- }, [range, shownStep]);
370
+ if (shown)
371
+ onViewRef.current?.({ range, step: picked });
372
+ }, [shown, range, picked]);
354
373
  // The steps always show where a range is drawn by day, so the buttons do not jump about; one
355
374
  // the range cannot make (any, under 24H and 7D) is disabled.
356
375
  const daily = ranges.some(r => RANGE[r].resolution === 'day');
357
376
  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" })] }) }) }))] }))] }));
377
+ 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 && (
378
+ // Over the cursor, or under it near the top; growing away from the nearer side.
379
+ _jsx("span", { className: "chart-tip", role: "tooltip", style: {
380
+ top: box.current.offsetTop + tip.y,
381
+ ...(tip.x < box.current.clientWidth / 2 ? { left: tip.x } : { right: box.current.clientWidth - tip.x }),
382
+ translate: tip.y < 48 ? '0 1rem' : '0 calc(-100% - 0.75rem)',
383
+ }, 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); 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" })] }) }) }))] }))] }));
359
384
  }
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.91.1",
3
+ "version": "0.94.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",