wiki-formant 0.112.0 → 0.113.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
@@ -466,6 +466,25 @@ return new ImageResponse(<SocialCard {...{ title, eyebrow, description }} {...fi
466
466
  - Fonts stay with the site: Next traces a font file only from a literal path in the app, and satori reads TTF or OTF.
467
467
  - `wiki-formant/seeded`'s `hashStr` and `seededRandom` are the deterministic randomness the art draws from, identical on server and browser.
468
468
 
469
+ ## Page banners
470
+
471
+ `wiki-formant/banner`: the banner a page is headed with, its own picture or art seeded by the page.
472
+
473
+ ```tsx
474
+ import { PageBanner, BannerArt } from 'wiki-formant/banner';
475
+
476
+ <PageBanner seed={page.title + page.tagPath} picture={banner && <Image src={banner} alt="" fill preload sizes="100vw" />}>
477
+ <Breadcrumbs items={crumbs} />
478
+ <h1>{page.title}</h1>
479
+ </PageBanner>
480
+ <span className="card-thumb"><BannerArt seed={page.title + page.tagPath} /></span>
481
+ ```
482
+
483
+ - Every page has a banner: a page without a picture gets art seeded by `seed`, the same on every render, server and browser. Seed it as the page's card thumbnail is seeded, so the two match.
484
+ - The art is inline SVG with a class per role, coloured by custom properties: `--banner-from` and `--banner-to` the gradient (transparent unset), `--banner-mid` the blobs and half the shapes (`--ui-muted` unset), `--banner-ink` the rest and the dots (`--ui-accent` unset), over `--banner-field` (`--ui-surface` unset). A category's palette or a section's band is a style on the frame.
485
+ - `mark` replaces the triangle with a wiki's own shape, as a path from its anchor, size and generator. `fill-opacity` on a role's class dims it under a title.
486
+ - The frame's 4:1 is a floor, not a height (`base.css`): the trail and title grow it rather than being cropped.
487
+
469
488
  ## Revisions and history
470
489
 
471
490
  `wiki-formant/revisions`: what changed between two versions of a page, and which bump to ask for. `wiki-formant/history`: what each revision changed, as a history view shows it.
@@ -0,0 +1,35 @@
1
+ import type { CSSProperties, ReactElement, ReactNode } from 'react';
2
+ /**
3
+ * The art's third kind of shape, as a path: a wiki's own mark. Given its
4
+ * anchor, its size and the generator, so a mark that turns draws its turn
5
+ * from the same sequence. The default is a triangle round the anchor.
6
+ */
7
+ export type BannerMark = (x: number, y: number, size: number, rand: () => number) => string;
8
+ /**
9
+ * The seeded field: a gradient at a seeded angle, three soft blobs, five to
10
+ * eight shapes, four bright dots. Its colours are custom properties
11
+ * (`base.css`): `--banner-from` and `--banner-to` the gradient, transparent
12
+ * unless set, `--banner-mid` the blobs and half the shapes, `--banner-ink`
13
+ * the other half and the dots.
14
+ */
15
+ export declare function BannerArt({ seed, mark, className }: {
16
+ seed: string;
17
+ mark?: BannerMark;
18
+ className?: string;
19
+ }): ReactElement;
20
+ /**
21
+ * A page's header: its picture, else its art, with the page's trail and title
22
+ * over it. `picture` is the app's own image element (`next/image` with `fill`,
23
+ * say), since this package has no `next`; it fills the frame. The frame's
24
+ * height is a floor of 4:1 that grows with what sits on it (`base.css`), so a
25
+ * long title on a phone never loses its trail. `style` carries a palette's
26
+ * custom properties.
27
+ */
28
+ export declare function PageBanner({ seed, picture, mark, className, style, children }: {
29
+ seed: string;
30
+ picture?: ReactNode;
31
+ mark?: BannerMark;
32
+ className?: string;
33
+ style?: CSSProperties;
34
+ children?: ReactNode;
35
+ }): ReactElement;
package/dist/banner.js ADDED
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { hashStr, seededRandom } from './seeded.js';
3
+ const W = 800;
4
+ const H = 200;
5
+ const px = (v) => Math.round(v);
6
+ const op = (v) => v.toFixed(2);
7
+ const triangle = (x, y, s, rand) => `M${Array.from({ length: 3 }, () => `${px(x + rand() * s * 2 - s)} ${px(y + rand() * s * 2 - s)}`).join('L')}Z`;
8
+ // Static strings, so a class checker sees every one.
9
+ const FILL = ['banner-art__mid', 'banner-art__ink'];
10
+ const LINE = ['banner-art__mid-line', 'banner-art__ink-line'];
11
+ /**
12
+ * The seeded field: a gradient at a seeded angle, three soft blobs, five to
13
+ * eight shapes, four bright dots. Its colours are custom properties
14
+ * (`base.css`): `--banner-from` and `--banner-to` the gradient, transparent
15
+ * unless set, `--banner-mid` the blobs and half the shapes, `--banner-ink`
16
+ * the other half and the dots.
17
+ */
18
+ export function BannerArt({ seed, mark = triangle, className = 'banner-art' }) {
19
+ const hash = hashStr(seed);
20
+ const rand = seededRandom(hash);
21
+ const id = `banner-${hash.toString(36)}`;
22
+ const shapes = [];
23
+ const angle = Math.floor(rand() * 360);
24
+ for (let i = 0; i < 3; i++) {
25
+ shapes.push(_jsx("circle", { className: FILL[0], cx: px(rand() * W), cy: px(rand() * H), r: px(60 + rand() * 120), opacity: op(0.15 + rand() * 0.2) }, `blob${i}`));
26
+ }
27
+ const count = 5 + Math.floor(rand() * 4);
28
+ for (let i = 0; i < count; i++) {
29
+ const x = rand() * W;
30
+ const y = rand() * H;
31
+ const tone = i % 2 === 0 ? 1 : 0;
32
+ const opacity = op(0.12 + rand() * 0.25);
33
+ const kind = Math.floor(rand() * 4);
34
+ const key = `shape${i}`;
35
+ if (kind === 0) {
36
+ shapes.push(_jsx("circle", { className: FILL[tone], cx: px(x), cy: px(y), r: px(8 + rand() * 40), opacity: opacity }, key));
37
+ }
38
+ else if (kind === 1) {
39
+ const w = px(20 + rand() * 80), h = px(15 + rand() * 50), rx = px(rand() * 12);
40
+ const turn = px(rand() * 60 - 30);
41
+ shapes.push(_jsx("rect", { className: FILL[tone], x: px(x), y: px(y), width: w, height: h, rx: rx, opacity: opacity, transform: `rotate(${turn} ${px(x)} ${px(y)})` }, key));
42
+ }
43
+ else if (kind === 2) {
44
+ shapes.push(_jsx("path", { className: FILL[tone], d: mark(x, y, 15 + rand() * 35, rand), opacity: opacity }, key));
45
+ }
46
+ else {
47
+ const r = px(12 + rand() * 30);
48
+ shapes.push(_jsx("circle", { className: LINE[tone], cx: px(x), cy: px(y), r: r, strokeWidth: (2 + rand() * 4).toFixed(1), opacity: opacity }, key));
49
+ }
50
+ }
51
+ for (let i = 0; i < 4; i++) {
52
+ shapes.push(_jsx("circle", { className: FILL[1], cx: px(rand() * W), cy: px(rand() * H), r: px(2 + rand() * 6), opacity: op(0.4 + rand() * 0.4) }, `dot${i}`));
53
+ }
54
+ return (_jsxs("svg", { className: className, viewBox: `0 0 ${W} ${H}`, preserveAspectRatio: "xMidYMid slice", "aria-hidden": true, focusable: "false", children: [_jsx("defs", { children: _jsxs("linearGradient", { id: id, gradientTransform: `rotate(${angle})`, children: [_jsx("stop", { offset: "0%", className: "banner-art__from" }), _jsx("stop", { offset: "100%", className: "banner-art__to" })] }) }), _jsx("rect", { width: W, height: H, fill: `url(#${id})` }), shapes] }));
55
+ }
56
+ /**
57
+ * A page's header: its picture, else its art, with the page's trail and title
58
+ * over it. `picture` is the app's own image element (`next/image` with `fill`,
59
+ * say), since this package has no `next`; it fills the frame. The frame's
60
+ * height is a floor of 4:1 that grows with what sits on it (`base.css`), so a
61
+ * long title on a phone never loses its trail. `style` carries a palette's
62
+ * custom properties.
63
+ */
64
+ export function PageBanner({ seed, picture, mark, className, style, children }) {
65
+ return (_jsxs("div", { className: className ? `page-banner ${className}` : 'page-banner', style: style, children: [picture ?? _jsx(BannerArt, { seed: seed, mark: mark }), children && _jsx("div", { className: "page-banner__body", children: children })] }));
66
+ }
package/dist/base.css CHANGED
@@ -463,6 +463,42 @@ tbody > tr:nth-child(even) > :is(th, td):not(.chart *) { background-image: linea
463
463
  }
464
464
  .breadcrumbs .separator { user-select: none; }
465
465
 
466
+ /* ---- PageBanner and BannerArt -----------------------------------------------
467
+ 4:1 is a floor, not a height: the ratio-holder and the body share one grid
468
+ cell, so the row is as tall as whichever needs more, and a long title on a
469
+ phone grows the banner rather than losing its trail off the top. The column
470
+ is minmax(0, 1fr), not auto, which would size to a long title's unwrapped
471
+ line. `max-height` caps only the picture, never the words. */
472
+ .page-banner {
473
+ position: relative;
474
+ display: grid;
475
+ grid-template-columns: minmax(0, 1fr);
476
+ overflow: hidden;
477
+ border-radius: var(--ui-radius);
478
+ background: var(--banner-field, var(--ui-surface));
479
+ }
480
+ .page-banner::before { content: ''; grid-area: 1 / 1; aspect-ratio: 4 / 1; max-height: 12rem; }
481
+ .page-banner > :is(img, picture, svg) { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
482
+ /* `relative` only so it paints above the picture. */
483
+ .page-banner__body {
484
+ grid-area: 1 / 1;
485
+ position: relative;
486
+ display: flex;
487
+ flex-direction: column;
488
+ justify-content: flex-end;
489
+ gap: 0.5rem;
490
+ min-width: 0;
491
+ padding: var(--ui-pad);
492
+ }
493
+ .page-banner__body > :is(h1, nav) { margin: 0; }
494
+ .banner-art { display: block; width: 100%; height: 100%; }
495
+ .banner-art__from { stop-color: var(--banner-from, transparent); }
496
+ .banner-art__to { stop-color: var(--banner-to, transparent); }
497
+ .banner-art__mid { fill: var(--banner-mid, var(--ui-muted)); }
498
+ .banner-art__ink { fill: var(--banner-ink, var(--ui-accent)); }
499
+ .banner-art__mid-line { fill: none; stroke: var(--banner-mid, var(--ui-muted)); }
500
+ .banner-art__ink-line { fill: none; stroke: var(--banner-ink, var(--ui-accent)); }
501
+
466
502
  /* ---- Pager and SnapDots -----------------------------------------------------
467
503
  One line that never wraps, the count in tabular figures so stepping does not
468
504
  shift it. A step is a bare control: its colour, size and hit area are the
package/dist/mcp.d.ts CHANGED
@@ -82,6 +82,8 @@ export type ToolSchema = {
82
82
  * calls has no way to tell a lookup from one that reaches a payment processor.
83
83
  */
84
84
  export type ToolAnnotations = {
85
+ /** The pre-2025-06-18 home of the label; `toolCard` mirrors `McpTool.title` here. */
86
+ title?: string;
85
87
  /** Does not modify anything. */
86
88
  readOnlyHint?: boolean;
87
89
  /** May destroy or overwrite state (only meaningful when not read-only). */
@@ -134,6 +136,10 @@ export interface McpTool {
134
136
  * One projection because the two copies drifted: the manifest's had no
135
137
  * `outputSchema`, so a descriptor said a tool answers with prose while
136
138
  * `tools/list` said it answers with `structuredContent`.
139
+ *
140
+ * The title goes out twice, top level and in `annotations`: clients and the
141
+ * Claude connectors directory that read only `annotations.title` otherwise
142
+ * report every tool untitled.
137
143
  */
138
144
  export declare function toolCard(tool: {
139
145
  name: string;
package/dist/mcp.js CHANGED
@@ -75,6 +75,10 @@ import { clientKey, rateLimit, rateLimitHeaders, withRateLimit } from './rate-li
75
75
  * One projection because the two copies drifted: the manifest's had no
76
76
  * `outputSchema`, so a descriptor said a tool answers with prose while
77
77
  * `tools/list` said it answers with `structuredContent`.
78
+ *
79
+ * The title goes out twice, top level and in `annotations`: clients and the
80
+ * Claude connectors directory that read only `annotations.title` otherwise
81
+ * report every tool untitled.
78
82
  */
79
83
  export function toolCard(tool) {
80
84
  return {
@@ -83,7 +87,9 @@ export function toolCard(tool) {
83
87
  description: tool.description,
84
88
  inputSchema: tool.inputSchema,
85
89
  ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : {}),
86
- ...(tool.annotations ? { annotations: tool.annotations } : {}),
90
+ ...(tool.annotations || tool.title
91
+ ? { annotations: { ...(tool.title ? { title: tool.title } : {}), ...tool.annotations } }
92
+ : {}),
87
93
  };
88
94
  }
89
95
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.112.0",
3
+ "version": "0.113.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",
@@ -253,6 +253,11 @@
253
253
  "import": "./dist/seeded.js",
254
254
  "default": "./dist/seeded.js"
255
255
  },
256
+ "./banner": {
257
+ "types": "./dist/banner.d.ts",
258
+ "import": "./dist/banner.js",
259
+ "default": "./dist/banner.js"
260
+ },
256
261
  "./og": {
257
262
  "types": "./dist/og.d.ts",
258
263
  "import": "./dist/og.js",