@smartmemory/wiki-components 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,91 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Regression Analytics LLC
6
+ Licensed Work: @smartmemory/wiki-components. The Licensed Work is (c) 2026 Regression
7
+ Analytics LLC.
8
+ Additional Use Grant: You may make production use of the Licensed Work,
9
+ provided that the total revenue of you and your
10
+ affiliates in your most recently completed fiscal
11
+ year did not exceed USD 1,000,000.
12
+ Change Date: Four years from the date the specific version of the
13
+ Licensed Work is first publicly distributed under
14
+ this License.
15
+ Change License: Apache License, Version 2.0
16
+
17
+ For information about alternative licensing arrangements for the Licensed
18
+ Work, including production use above the Additional Use Grant, contact
19
+ help@smartmemory.ai.
20
+
21
+ Notice
22
+
23
+ License text copyright © 2017 MariaDB Corporation Ab, All Rights Reserved.
24
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
25
+
26
+ Terms
27
+
28
+ The Licensor hereby grants you the right to copy, modify, create derivative
29
+ works, redistribute, and make non-production use of the Licensed Work. The
30
+ Licensor may make an Additional Use Grant, above, permitting limited
31
+ production use.
32
+
33
+ Effective on the Change Date, or the fourth anniversary of the first publicly
34
+ available distribution of a specific version of the Licensed Work under this
35
+ License, whichever comes first, the Licensor hereby grants you rights under
36
+ the terms of the Change License, and the rights granted in the paragraph
37
+ above terminate.
38
+
39
+ If your use of the Licensed Work does not comply with the requirements
40
+ currently in effect as described in this License, you must purchase a
41
+ commercial license from the Licensor, its affiliated entities, or authorized
42
+ resellers, or you must refrain from using the Licensed Work.
43
+
44
+ All copies of the original and modified Licensed Work, and derivative works
45
+ of the Licensed Work, are subject to this License. This License applies
46
+ separately for each version of the Licensed Work and the Change Date may vary
47
+ for each version of the Licensed Work released by Licensor.
48
+
49
+ You must conspicuously display this License on each original or modified copy
50
+ of the Licensed Work. If you receive the Licensed Work in original or
51
+ modified form from a third party, the terms and conditions set forth in this
52
+ License apply to your use of that work.
53
+
54
+ Any use of the Licensed Work in violation of this License will automatically
55
+ terminate your rights under this License for the current and all other
56
+ versions of the Licensed Work.
57
+
58
+ This License does not grant you any right in any trademark or logo of
59
+ Licensor or its affiliates (provided that you may use a trademark or logo of
60
+ Licensor as expressly required by this License).
61
+
62
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
63
+ AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
64
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
65
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
66
+ TITLE.
67
+
68
+ MariaDB hereby grants you permission to use this License’s text to license
69
+ your works, and to refer to it using the trademark “Business Source License”,
70
+ as long as you comply with the Covenants of Licensor below.
71
+
72
+ Covenants of Licensor
73
+
74
+ In consideration of the right to use this License’s text and the “Business
75
+ Source License” name and trademark, Licensor covenants to MariaDB, and to all
76
+ other recipients of the licensed work to be provided by Licensor:
77
+
78
+ 1. To specify as the Change License the GPL Version 2.0 or any later version,
79
+ or a license that is compatible with GPL Version 2.0 or a later version,
80
+ where “compatible” means that software provided under the Change License can
81
+ be included in a program with software provided under GPL Version 2.0 or a
82
+ later version. Licensor may specify additional Change Licenses without
83
+ limitation.
84
+
85
+ 2. To either: (a) specify an additional grant of rights to use that does not
86
+ impose any additional restriction on the right granted in this License, as
87
+ the Additional Use Grant; or (b) insert the text “None”.
88
+
89
+ 3. To specify a Change Date.
90
+
91
+ 4. Not to modify this License in any other way.
package/README.md CHANGED
@@ -1,3 +1,63 @@
1
- # Temporary Holding Version
1
+ # @smartmemory/wiki-components
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The wiki's **feature hub** and **search** screens as router-free React components (WIKI-UI-COMPONENTS-1). Every byte of data
4
+ comes from an injected `DataClient`; no component fetches, imports a router, or uses react-query. The wiki's own `/ui/` app
5
+ renders them through thin route wrappers (`ui/src/pages/FeatureHub.jsx`, `ui/src/pages/SearchPage.jsx`) with a fetch-based
6
+ client (`ui/src/api/dataClient.jsx`). Compose's wiki tab (COMPOSE-WIKI-TAB-1) supplies its own client.
7
+
8
+ ## Using @smartmemory/wiki-components
9
+
10
+ Published on npm under the `@smartmemory` scope (WIKI-COMPONENTS-PUBLISH-1, BUSL-1.1, see `LICENSE`).
11
+
12
+ ```sh
13
+ npm install @smartmemory/wiki-components react react-dom date-fns lucide-react
14
+ ```
15
+
16
+ - **What ships:** `src/` as plain ESM JSX plus the hand-written `.d.ts` files, `README.md`, `LICENSE`. There is no build
17
+ step and no `dist/`: the wiki's own `/ui/` app consumes the same `src/` through its `file:` dependency, so one tree
18
+ serves both, and the only npm host (Compose) is a Vite app that transforms JSX from `node_modules` itself.
19
+ - **Host must bundle JSX:** a Vite (or other bundler) host with the React plugin. Node cannot import the package directly.
20
+ - **One React copy:** installed from npm the package's bare imports already resolve from the host's `node_modules`; keep
21
+ `react`, `react-dom`, `lucide-react`, `date-fns` in `resolve.dedupe` so a linked checkout behaves the same.
22
+ - **Tailwind:** add `node_modules/@smartmemory/wiki-components/src/**/*.{js,jsx}` to `content` (Compose does this through
23
+ its scoped `wiki-tab.tailwind.config.js`).
24
+ - **Releasing:** the owner publishes from a local checkout (`cd ui/packages/wiki-components && npm pack --dry-run && npm
25
+ publish --access public`); bump `version` here and the dependency range in Compose's `package.json` together.
26
+
27
+ ## Contract
28
+
29
+ `src/data-client.d.ts` is the contract: four calls (`identity`, `featureHub`, `decisions`, `search`), the endpoint and
30
+ request body each maps to on the wiki API, and the result types (from `wiki/contracts/query.schema.json`). The same list is
31
+ runtime data in `DATA_CLIENT_CALLS`; `ui/test/wiki-components.test.jsx` pins the typed file, the table and the app's client
32
+ to each other. `<WikiDataProvider>` refuses a client missing a method.
33
+
34
+ ## Use
35
+
36
+ ```jsx
37
+ import { WikiDataProvider, FeatureHub, Search, wikiPath } from "@smartmemory/wiki-components";
38
+
39
+ <WikiDataProvider client={myClient}>
40
+ <FeatureHub featureId="CORE-1" workspace="wiki-eng" />
41
+ <Search query="recall" workspaces={["wiki-eng", "wiki-gtm"]}
42
+ onNavigate={(target) => /* {kind: "search", query?, workspace?} | {kind: "note", workspace, docId} */ go(target)}
43
+ hrefFor={(target) => `http://127.0.0.1:8791/ui${wikiPath(target)}`} />
44
+ </WikiDataProvider>
45
+ ```
46
+
47
+ - `workspace` / `workspaces` are optional: without them a screen calls `client.identity()` and uses the caller's grants
48
+ (FeatureHub the first one).
49
+ - `onNavigate(target)` receives a plain left click on a link and the search box's submit. `hrefFor(target)` is each link's
50
+ href (modified clicks, open in new tab); `wikiPath(target)` is the target's path in the wiki's own `/ui/` app.
51
+
52
+ ## Host requirements
53
+
54
+ - **Peers:** `react` and `react-dom` `>=18 <20`, `date-fns` 3, `lucide-react`. The package ships JSX source (no build step);
55
+ a Vite host transforms it (installed from npm or linked). Consumed as `file:` dependency, its bare imports resolve from its real path, so a host outside
56
+ this repo must list `react`, `react-dom` (and `lucide-react`, `date-fns`) in `resolve.dedupe` or it loads the wiki's
57
+ React 18 copy next to its own.
58
+ - **Styles:** Tailwind classes. Add `<this dir>/src/**/*.{js,jsx}` to the host's Tailwind `content`. Colours read the wiki's
59
+ CSS variables through its Tailwind theme (`ui/tailwind.config.js` `colors`: `ground`, `ink`, `ink2`, `onblue`, and the
60
+ remapped `neutral`/`blue`/`red`/`emerald`/`amber`/`violet`/`orange` scales over `--lp-*` and `--w-*`, defined in
61
+ `ui/src/index.css` and the design system's `landing.css`). Search's list layout uses `.list-page` / `.list-scroll` from
62
+ `ui/src/index.css`. A host that wants the wiki look maps those variables (or the same Tailwind colour names) to its own
63
+ tokens.
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@smartmemory/wiki-components",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The wiki's feature hub and search screens as router-free React components fed by an injected DataClient (WIKI-UI-COMPONENTS-1). Ships JSX source for a bundler host (Vite).",
5
+ "license": "BUSL-1.1",
6
+ "author": "Regression Analytics LLC",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/smartmemory/smart-memory-wiki.git",
10
+ "directory": "ui/packages/wiki-components"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "src/index.js",
17
+ "types": "src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "default": "./src/index.js"
22
+ },
23
+ "./data-client": {
24
+ "types": "./src/data-client.d.ts"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "src",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "peerDependencies": {
34
+ "react": ">=18 <20",
35
+ "react-dom": ">=18 <20",
36
+ "date-fns": "^3.6.0",
37
+ "lucide-react": ">=0.475.0"
38
+ },
39
+ "sideEffects": false
40
+ }
@@ -0,0 +1,74 @@
1
+ import React from "react";
2
+ import { useClientCall, useDataClient, useWorkspaces } from "./data-client";
3
+ import ErrorBox from "./parts/ErrorBox";
4
+ import Section from "./parts/Section";
5
+ import DocRow from "./parts/DocRow";
6
+ import Loading from "./parts/Loading";
7
+ import StatusChip from "./parts/StatusChip";
8
+ import EntryRows from "./parts/EntryRows";
9
+ import FeatureSummary from "./parts/FeatureSummary";
10
+
11
+ /** True when a product's only evidence is the feature id's prefix (the weakest kind, WIKI-FEATURE-ASPECTS-1). */
12
+ export const prefixOnly = (feature, product) => {
13
+ const ev = feature.product_evidence?.[product] || [];
14
+ return ev.length > 0 && ev.every((e) => e.startsWith("id-prefix"));
15
+ };
16
+
17
+ /** The index facets of one feature: products (primary bold, hollow on prefix-only evidence), categories, initiatives. */
18
+ function FacetChips({ feature }) {
19
+ const tag = (text, cls, title) => <span key={text} title={title} className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] ${cls}`}>{text}</span>;
20
+ const products = (feature.products || []).map((p) => tag(
21
+ p,
22
+ `${p === feature.primary_product ? "font-semibold" : "font-medium"} ${prefixOnly(feature, p) ? "border-dashed border-violet-300 bg-transparent text-violet-600" : "border-violet-200 bg-violet-50 text-violet-700"}`,
23
+ `product${p === feature.primary_product ? " (primary)" : ""}: ${(feature.product_evidence?.[p] || []).join(", ") || "no evidence"}`,
24
+ ));
25
+ const plain = (xs, kind) => (xs || []).map((x) => tag(x, "font-medium border-neutral-200 bg-neutral-50 text-neutral-600", kind));
26
+ const chips = [...products, ...plain(feature.categories, "category"), ...plain(feature.initiatives, "initiative")];
27
+ return chips.length ? <div data-testid="facet-chips" className="mt-2 flex flex-wrap items-center gap-1.5">{chips}</div> : null;
28
+ }
29
+
30
+ /** One feature: its docs grouped by kind, then its decisions. `workspace` is the workspace to read (default: the caller's
31
+ * first grant, from the DataClient's identity()). Data: DataClient featureHub + decisions. No links, so no onNavigate. */
32
+ export default function FeatureHub({ featureId, workspace }) {
33
+ const client = useDataClient();
34
+ const ws = useWorkspaces(workspace === undefined ? undefined : [workspace]);
35
+ const on = ws.workspaces?.[0];
36
+ const hub = useClientCall(["feature_hub", on, featureId], () => client.featureHub({ workspace: on, featureId }), { enabled: !!on, keepPrevious: true });
37
+ const decisions = useClientCall(["decisions", on, featureId], () => client.decisions({ workspace: on, featureId }), { enabled: !!on, keepPrevious: true });
38
+ const loading = ws.isLoading || hub.isLoading;
39
+ const feature = hub.data?.feature;
40
+ const docs = hub.data?.items || [];
41
+ const kinds = [...new Set(docs.map((d) => d.kind))];
42
+
43
+ return (
44
+ <div>
45
+ <header className="mb-10">
46
+ <div className="mb-1 text-xs font-medium uppercase tracking-wider text-neutral-400">Feature hub</div>
47
+ <h1 className="font-mono text-2xl font-semibold tracking-tight">{featureId}</h1>
48
+ {feature ? (
49
+ <>
50
+ <p className="mt-1 text-[15px] text-neutral-700">{feature.title}</p>
51
+ <FeatureSummary feature={feature} className="mt-1 max-w-3xl" />
52
+ <div className="mt-3 flex flex-wrap items-center gap-3">
53
+ <StatusChip status={feature.roadmap_status} />
54
+ {feature.status_source === "none" && <span className="text-xs font-medium text-amber-700">untracked</span>}
55
+ <span className="text-xs text-neutral-500">{feature.doc_count} docs</span>
56
+ {feature.owner && <span className="text-xs text-neutral-500">owner {feature.owner}</span>}
57
+ </div>
58
+ <FacetChips feature={feature} />
59
+ </>
60
+ ) : !loading && <p className="mt-1 text-sm text-neutral-400">Not on the roadmap.</p>}
61
+ </header>
62
+ <ErrorBox error={ws.error || hub.error} />
63
+ {loading ? <Loading /> : kinds.map((k) => (
64
+ <Section key={k} title={k} meta={docs.filter((d) => d.kind === k).length}>
65
+ {docs.filter((d) => d.kind === k).map((d) => <DocRow key={d.doc_id} doc={d} />)}
66
+ </Section>
67
+ ))}
68
+ <Section title="Decisions" meta={decisions.data?.total}>
69
+ <ErrorBox error={decisions.error} />
70
+ <EntryRows entries={decisions.data?.items || []} empty="No decisions recorded." />
71
+ </Section>
72
+ </div>
73
+ );
74
+ }
package/src/Search.jsx ADDED
@@ -0,0 +1,85 @@
1
+ import React from "react";
2
+ import { useClientCall, useDataClient, useWorkspaces } from "./data-client";
3
+ import ErrorBox from "./parts/ErrorBox";
4
+ import SearchBox from "./parts/SearchBox";
5
+ import PageHeader from "./parts/PageHeader";
6
+ import Loading from "./parts/Loading";
7
+ import ListPage from "./parts/ListPage";
8
+ import SearchGroup from "./parts/SearchGroup";
9
+ import NavLink from "./parts/NavLink";
10
+ import { wsName } from "./lib";
11
+
12
+ const chip = (on) => `rounded-full border px-3 py-1 text-[13px] transition-colors ${on ? "border-blue-500 bg-blue-500 text-onblue" : "border-neutral-200 text-ink2 hover:border-blue-500 hover:text-ink"}`;
13
+
14
+ /** The search screen. `query` is the search text (capped at 256 characters, as the search query allows). `workspace`
15
+ * searches that one workspace; without it, every workspace in `workspaces` (default: the caller's grants, from the
16
+ * DataClient's identity()), in parallel, one section per workspace. A workspace outside the grants is ignored.
17
+ * Each hit, each scope chip and the search box are NavTargets: `onNavigate(target)` follows one, `hrefFor(target)` is
18
+ * its href. Data: DataClient search, one call per workspace. */
19
+ export default function Search({ query = "", workspace: only = null, workspaces: given, onNavigate, hrefFor }) {
20
+ const client = useDataClient();
21
+ const ws = useWorkspaces(given);
22
+ const workspaces = ws.workspaces || [];
23
+ const q = String(query || "").slice(0, 256); // the search query allows 1 to 256 characters
24
+ const scope = workspaces.includes(only) ? [only] : workspaces;
25
+ const call = useClientCall(
26
+ ["search", scope, q],
27
+ () => Promise.allSettled(scope.map((w) => client.search({ workspace: w, q }))),
28
+ { enabled: !!q && scope.length > 0 },
29
+ );
30
+ const runs = scope.map((_, i) => {
31
+ const r = call.data?.[i];
32
+ return { data: r?.status === "fulfilled" ? r.value : undefined, error: r?.status === "rejected" ? r.reason : null };
33
+ });
34
+ const scoped = (w) => ({ kind: "search", ...(q ? { query: q } : {}), ...(w ? { workspace: w } : {}) });
35
+ const loading = ws.isLoading || (!!q && call.isLoading);
36
+ const found = runs.some((r) => r.data?.groups?.length);
37
+ const link = { onNavigate, hrefFor };
38
+
39
+ return (
40
+ <ListPage
41
+ header={(
42
+ <>
43
+ <PageHeader title="Search" />
44
+ <div className="max-w-2xl"><SearchBox key={q + (only || "")} initial={q} autoFocus workspace={scope.length === 1 && workspaces.length > 1 ? only : undefined} onNavigate={onNavigate} /></div>
45
+ {workspaces.length > 1 && (
46
+ <div className="mt-4 flex flex-wrap gap-2" aria-label="Search in">
47
+ <NavLink {...link} target={scoped(null)} className={chip(scope.length > 1)}>All workspaces</NavLink>
48
+ {workspaces.map((w) => <NavLink {...link} key={w} target={scoped(w)} className={chip(scope.length === 1 && scope[0] === w)}>{wsName(w)}</NavLink>)}
49
+ </div>
50
+ )}
51
+ </>
52
+ )}
53
+ >
54
+ <div className="mt-6">
55
+ <ErrorBox error={ws.error} className="mb-4" />
56
+ {runs.map((r, i) => <ErrorBox key={scope[i]} error={r.error} className="mb-4" />)}
57
+ {!q ? <p className="text-sm text-neutral-400">Type a query and press Enter.</p>
58
+ : loading ? <Loading text="Searching…" />
59
+ : !found ? <p className="text-sm text-neutral-400">No results for “{q}”.</p>
60
+ : runs.map((r, i) => {
61
+ const w = scope[i];
62
+ const groups = r.data?.groups || [];
63
+ if (!groups.length) return null;
64
+ return (
65
+ <section key={w} className="mb-10">
66
+ {scope.length > 1 && <h2 className="mb-2 text-lg font-semibold tracking-tight text-ink">{wsName(w)} <span className="font-mono text-xs font-normal text-neutral-500">{w}</span></h2>}
67
+ {groups.map((g) => (
68
+ <SearchGroup key={g.label} title={g.label} total={g.total} items={g.items.map((h) => (
69
+ <NavLink {...link} key={h.doc_id} target={{ kind: "note", workspace: w, docId: h.doc_id }} className="grid grid-cols-[1fr_auto] gap-3 rounded-md px-3 py-2 hover:bg-neutral-50">
70
+ <div className="min-w-0">
71
+ <div className="truncate font-medium text-ink">{h.title}</div>
72
+ <div className="truncate font-mono text-xs text-neutral-500">{h.path}</div>
73
+ <div className="mt-0.5 text-[13px] text-neutral-600">{h.snippet}</div>
74
+ </div>
75
+ <span className="text-xs tabular-nums text-neutral-400">rank {h.rank}</span>
76
+ </NavLink>
77
+ ))} />
78
+ ))}
79
+ </section>
80
+ );
81
+ })}
82
+ </div>
83
+ </ListPage>
84
+ );
85
+ }
@@ -0,0 +1,117 @@
1
+ // WIKI-UI-COMPONENTS-1: the DataClient contract. Every call the FeatureHub and Search screens make, and nothing else.
2
+ // A host (the wiki's /ui/ app, Compose's wiki tab) passes one object with these async methods to <WikiDataProvider>.
3
+ // The components never fetch: each call below is the whole of their I/O.
4
+ //
5
+ // Traced from the pages as they were before the move (ui/src/pages/FeatureHub.jsx, ui/src/pages/SearchPage.jsx at c1fea67)
6
+ // through ui/src/api/hooks.jsx (useNamedQuery, IdentityProvider) to ui/src/api/client.js (query, identity):
7
+ //
8
+ // method endpoint request body (the Query envelope, wiki/contracts/query.schema.json)
9
+ // identity GET /identity (none) -> identity_response, wiki/contracts/identity.schema.json
10
+ // featureHub POST /query/feature_hub {query: "feature_hub", workspace, filters: {feature_id}, page: {size: 200}}
11
+ // decisions POST /query/decisions {query: "decisions", workspace, filters: {feature_id}, page: {size: 50}}
12
+ // search POST /query/search {query: "search", workspace, filters: {q}, page: {}}
13
+ //
14
+ // The same table is runtime data in data-client.jsx (DATA_CLIENT_CALLS); a test pins the two to each other.
15
+ // A rejected call should reject with an Error carrying `code` (the query_error code) so the screens can show it.
16
+
17
+ /** A workspace id the caller holds, e.g. "wiki-eng". */
18
+ export type Workspace = string;
19
+
20
+ /** GET /identity (identity.schema.json identity_response). */
21
+ export interface Identity {
22
+ principal: string;
23
+ workspaces: Workspace[];
24
+ }
25
+
26
+ /** query.schema.json $defs/doc_item */
27
+ export interface DocItem {
28
+ doc_id: string;
29
+ source_id: string;
30
+ path: string;
31
+ title: string;
32
+ kind: string;
33
+ feature_ids: string[];
34
+ areas: string[];
35
+ mtime: string;
36
+ text_mode: string;
37
+ missing_reason: string | null;
38
+ }
39
+
40
+ /** query.schema.json $defs/feature_item (the fields beyond `required` are optional on older hosts). */
41
+ export interface FeatureItem {
42
+ feature_id: string;
43
+ title: string;
44
+ roadmap_status: string;
45
+ folder: string;
46
+ owner: string | null;
47
+ current_decision_ref: string | null;
48
+ latest_output_ref: string | null;
49
+ doc_count: number;
50
+ areas: string[];
51
+ products?: string[];
52
+ primary_product?: string | null;
53
+ product_evidence?: Record<string, string[]>;
54
+ categories?: string[];
55
+ initiatives?: string[];
56
+ repos?: string[];
57
+ status_source?: string | null;
58
+ summary?: string;
59
+ archived?: boolean;
60
+ }
61
+
62
+ /** query.schema.json $defs/entry_item */
63
+ export interface EntryItem {
64
+ entry_id: string;
65
+ parent_doc_id: string;
66
+ kind: string;
67
+ ts: string;
68
+ text: string;
69
+ refs: string[];
70
+ status?: string;
71
+ }
72
+
73
+ /** query.schema.json $defs/search_hit */
74
+ export interface SearchHit {
75
+ doc_id: string;
76
+ title: string;
77
+ path: string;
78
+ snippet: string;
79
+ rank: number;
80
+ }
81
+
82
+ /** query.schema.json $defs/query_result */
83
+ export interface QueryResult<T> {
84
+ items: T[];
85
+ total: number;
86
+ next_cursor: string | null;
87
+ unresolved_count?: number;
88
+ generated_at: string;
89
+ }
90
+
91
+ /** x-named-queries.feature_hub.result: the feature's docs plus the feature itself (null when it is not on the roadmap). */
92
+ export interface FeatureHubResult extends QueryResult<DocItem> {
93
+ feature: FeatureItem | null;
94
+ }
95
+
96
+ /** x-named-queries.search.result (a grouped_result). */
97
+ export interface SearchResult {
98
+ groups: { label: string; items: SearchHit[]; total: number; next_cursor: string | null }[];
99
+ generated_at: string;
100
+ }
101
+
102
+ export interface DataClient {
103
+ /** GET /identity: who the caller is and which workspaces it holds. Asked only when a screen is not given its workspace(s). */
104
+ identity(): Promise<Identity>;
105
+ /** POST /query/feature_hub, filters {feature_id}, page {size: 200}. */
106
+ featureHub(args: { workspace: Workspace; featureId: string }): Promise<FeatureHubResult>;
107
+ /** POST /query/decisions, filters {feature_id}, page {size: 50}. */
108
+ decisions(args: { workspace: Workspace; featureId: string }): Promise<QueryResult<EntryItem>>;
109
+ /** POST /query/search, filters {q} (1 to 256 characters), page {}. One call per workspace searched. */
110
+ search(args: { workspace: Workspace; q: string }): Promise<SearchResult>;
111
+ }
112
+
113
+ /** Where a screen asks the host to go (Search only; FeatureHub has no links). The host maps it to its own route
114
+ * (the /ui/ app: wikiPath()). */
115
+ export type NavTarget =
116
+ | { kind: "search"; query?: string; workspace?: Workspace }
117
+ | { kind: "note"; workspace: Workspace; docId: string };
@@ -0,0 +1,57 @@
1
+ import React, { createContext, useContext, useEffect, useRef, useState } from "react";
2
+
3
+ // The DataClient's calls as data (types and the traced endpoints: data-client.d.ts). `endpoint` and `page` say what the
4
+ // host's client must send, so a host can be checked against the screens' needs without rendering them.
5
+ export const DATA_CLIENT_CALLS = [
6
+ { method: "identity", endpoint: "GET /identity" },
7
+ { method: "featureHub", endpoint: "POST /query/feature_hub", query: "feature_hub", page: { size: 200 } },
8
+ { method: "decisions", endpoint: "POST /query/decisions", query: "decisions", page: { size: 50 } },
9
+ { method: "search", endpoint: "POST /query/search", query: "search", page: {} },
10
+ ];
11
+
12
+ const Ctx = createContext(null);
13
+
14
+ /** Hands `client` (a DataClient) to every screen inside. A client missing a method is refused here, not on first use. */
15
+ export function WikiDataProvider({ client, children }) {
16
+ const missing = DATA_CLIENT_CALLS.map((c) => c.method).filter((m) => typeof client?.[m] !== "function");
17
+ if (missing.length) throw new Error(`WikiDataProvider: the DataClient has no ${missing.join(", ")}`);
18
+ return <Ctx.Provider value={client}>{children}</Ctx.Provider>;
19
+ }
20
+
21
+ export function useDataClient() {
22
+ const client = useContext(Ctx);
23
+ if (!client) throw new Error("wiki components need a <WikiDataProvider client={...}> above them");
24
+ return client;
25
+ }
26
+
27
+ /** One DataClient call keyed by `key` (any JSON value): `{data, error, isLoading}`. A new key asks again; an answer for a
28
+ * key no longer current is dropped. `keepPrevious` shows the last answer while the next loads (react-query's
29
+ * keepPreviousData, which the feature hub used before the move). `enabled: false` asks nothing. */
30
+ export function useClientCall(key, run, { enabled = true, keepPrevious = false } = {}) {
31
+ const k = JSON.stringify(key);
32
+ const runRef = useRef(run);
33
+ runRef.current = run;
34
+ const [state, setState] = useState({ key: undefined, data: undefined, error: null });
35
+ useEffect(() => {
36
+ if (!enabled) return undefined;
37
+ let live = true;
38
+ Promise.resolve()
39
+ .then(() => runRef.current())
40
+ .then(
41
+ (data) => { if (live) setState({ key: k, data, error: null }); },
42
+ (error) => { if (live) setState({ key: k, data: undefined, error }); },
43
+ );
44
+ return () => { live = false; };
45
+ }, [k, enabled]);
46
+ const current = state.key === k;
47
+ const data = current || keepPrevious ? state.data : undefined;
48
+ return { data, error: current ? state.error : null, isLoading: enabled && !current && data === undefined };
49
+ }
50
+
51
+ /** The workspace(s) a screen works in: the host's, when it passes them, else the caller's grants from `identity()`. */
52
+ export function useWorkspaces(given) {
53
+ const client = useDataClient();
54
+ const id = useClientCall(["identity"], () => client.identity(), { enabled: given === undefined });
55
+ if (given !== undefined) return { workspaces: given, error: null, isLoading: false };
56
+ return { workspaces: id.data?.workspaces, error: id.error, isLoading: id.isLoading };
57
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Types for @smartmemory/wiki-components. The DataClient contract and NavTarget: data-client.d.ts.
2
+ import type { ReactElement, ReactNode } from "react";
3
+ import type { DataClient, NavTarget, Workspace } from "./data-client";
4
+
5
+ export * from "./data-client";
6
+
7
+ export interface NavProps {
8
+ /** Follow a target (a plain left click, or a search box submit). */
9
+ onNavigate?: (target: NavTarget) => void;
10
+ /** The href a target's link carries (open in new tab, status bar). Default "#". */
11
+ hrefFor?: (target: NavTarget) => string;
12
+ }
13
+
14
+ export function WikiDataProvider(props: { client: DataClient; children?: ReactNode }): ReactElement;
15
+ export function useDataClient(): DataClient;
16
+
17
+ /** One feature's docs (grouped by kind) and decisions. `workspace` default: the first of identity().workspaces. */
18
+ export function FeatureHub(props: { featureId: string; workspace?: Workspace }): ReactElement;
19
+
20
+ /** Search across `workspaces` (default: identity().workspaces), or only `workspace` when it is one of them. */
21
+ export function Search(props: { query?: string; workspace?: Workspace | null; workspaces?: Workspace[] } & NavProps): ReactElement;
22
+
23
+ export const DATA_CLIENT_CALLS: { method: keyof DataClient; endpoint: string; query?: string; page?: Record<string, number> }[];
24
+
25
+ /** A NavTarget as a path of the wiki's /ui/ app (relative to its basename, e.g. "/search?q=x"). */
26
+ export function wikiPath(target: NavTarget): string;
27
+ export function wsName(workspace: Workspace): string;
package/src/index.js ADDED
@@ -0,0 +1,20 @@
1
+ // @smartmemory/wiki-components (WIKI-UI-COMPONENTS-1): the wiki's feature hub and search screens, router-free, with all
2
+ // data from an injected DataClient (data-client.d.ts). The wiki's /ui/ app and Compose's wiki tab both render these.
3
+ export { default as FeatureHub, prefixOnly } from "./FeatureHub";
4
+ export { default as Search } from "./Search";
5
+ export { WikiDataProvider, useDataClient, useClientCall, DATA_CLIENT_CALLS } from "./data-client";
6
+ export { wikiPath, wsName, wsUrl, noteUrl, toDate, fmtDate, summaryOf } from "./lib";
7
+ // The parts the screens are built from; the /ui/ app's other pages use them too (ui/src/components/wiki/* re-export them).
8
+ export { default as ErrorBox } from "./parts/ErrorBox";
9
+ export { default as Section } from "./parts/Section";
10
+ export { default as DocRow } from "./parts/DocRow";
11
+ export { default as TypeBadge } from "./parts/TypeBadge";
12
+ export { default as Loading } from "./parts/Loading";
13
+ export { default as StatusChip } from "./parts/StatusChip";
14
+ export { default as EntryRows } from "./parts/EntryRows";
15
+ export { default as FeatureSummary } from "./parts/FeatureSummary";
16
+ export { default as PageHeader } from "./parts/PageHeader";
17
+ export { default as ListPage } from "./parts/ListPage";
18
+ export { default as SearchGroup } from "./parts/SearchGroup";
19
+ export { default as SearchBox } from "./parts/SearchBox";
20
+ export { default as NavLink } from "./parts/NavLink";
package/src/lib.js ADDED
@@ -0,0 +1,28 @@
1
+ import { format } from "date-fns";
2
+
3
+ // Helpers the screens share with the rest of the /ui/ app (ui/src/lib/wiki.js re-exports them: one home).
4
+
5
+ // WIKI-LANDING-UI-1: workspaces under human names. The id itself stays visible next to the name (small, mono). The table
6
+ // names the shared workspaces only: any other id reads as its title-cased tail, so no private name is in the bundle.
7
+ const WS_NAMES = { eng: "Engineering", gtm: "Go-to-market" };
8
+ export const wsName = (ws) => {
9
+ const tail = String(ws || "").replace(/^wiki-/, "");
10
+ return WS_NAMES[tail] || tail.split(/[-_]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ") || "Workspace";
11
+ };
12
+ export const wsUrl = (ws) => `/w/${encodeURIComponent(ws)}`;
13
+ export const noteUrl = (ws, id) => `${wsUrl(ws)}/note?id=${encodeURIComponent(id)}`;
14
+
15
+ export const toDate = (d) => (d ? new Date(d.length <= 10 || /([zZ]|[+-]\d\d:?\d\d)$/.test(d) ? d : d + "Z") : null);
16
+ export const fmtDate = (d) => (d ? format(toDate(d), "MMM d, yyyy") : "—");
17
+ /** A feature's index summary: the trimmed string, or null when absent or blank (nothing is drawn for null). */
18
+ export const summaryOf = (feature) => (typeof feature?.summary === "string" && feature.summary.trim() ? feature.summary.trim() : null);
19
+
20
+ /** A NavTarget (data-client.d.ts) as a route of the wiki's own /ui/ app, relative to its basename. */
21
+ export function wikiPath(target) {
22
+ if (target.kind === "note") return noteUrl(target.workspace, target.docId);
23
+ if (target.kind === "search") {
24
+ const p = new URLSearchParams({ ...(target.query ? { q: target.query } : {}), ...(target.workspace ? { workspace: target.workspace } : {}) });
25
+ return `/search?${p}`;
26
+ }
27
+ throw new Error(`wikiPath: unknown target kind ${target.kind}`);
28
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+ import TypeBadge from "./TypeBadge";
3
+ import { fmtDate } from "../lib";
4
+
5
+ export default function DocRow({ doc, right }) {
6
+ return (
7
+ <div className="grid items-center gap-3 rounded-md px-3 py-2 hover:bg-neutral-50 grid-cols-[6.5rem_1fr_auto] sm:grid-cols-[6.5rem_1fr_16rem_6.5rem]">
8
+ <TypeBadge type={doc.kind} />
9
+ <span className="truncate font-medium text-neutral-900">{doc.title}</span>
10
+ <span className="hidden truncate font-mono text-xs text-neutral-500 sm:block" title={doc.path}>{doc.path}</span>
11
+ <span className="hidden text-right text-xs tabular-nums text-neutral-500 sm:block">{fmtDate(doc.mtime)}</span>
12
+ {right}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,12 @@
1
+ import React from "react";
2
+ import { fmtDate } from "../lib";
3
+
4
+ export default function EntryRows({ entries, empty = "No entries." }) {
5
+ if (!entries.length) return <p className="px-3 text-sm text-neutral-400">{empty}</p>;
6
+ return entries.map((e) => (
7
+ <div key={e.entry_id} className="grid grid-cols-[6.5rem_1fr] gap-3 px-3 py-1.5 text-[13px]">
8
+ <span className="text-xs tabular-nums text-neutral-500">{fmtDate(e.ts)}</span>
9
+ <span className="text-neutral-800">{e.text}</span>
10
+ </div>
11
+ ));
12
+ }
@@ -0,0 +1,10 @@
1
+ import React from "react";
2
+
3
+ export default function ErrorBox({ error, className = "" }) {
4
+ if (!error) return null;
5
+ return (
6
+ <div role="alert" className={`rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 ${className}`}>
7
+ {error.message} <span className="font-mono text-xs text-red-500">({error.code || "error"})</span>
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,8 @@
1
+ import React from "react";
2
+ import { summaryOf } from "../lib";
3
+
4
+ /** The one-line summary the features index carries (WIKI-FEATURE-VIEWS-FU-1): a muted line, or nothing when absent. */
5
+ export default function FeatureSummary({ feature, className = "" }) {
6
+ const text = summaryOf(feature);
7
+ return text ? <p data-testid="feature-summary" className={`text-xs text-neutral-500 ${className}`}>{text}</p> : null;
8
+ }
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+
3
+ /** The one layout of every long list page (WIKI-UNCLASSIFIED-OVERFLOW-1): the header, the filters and the pager stay put
4
+ * and the rows scroll inside their own panel, so the page itself never grows with the list. Height and scrolling are
5
+ * CSS (`.list-page`, `.list-scroll` in index.css). A table inside keeps its column heads pinned (Table). */
6
+ export default function ListPage({ header, filters, foot, children }) {
7
+ return (
8
+ <div className="list-page">
9
+ {header}
10
+ {filters && <div className="mb-3 shrink-0">{filters}</div>}
11
+ <div className="list-scroll" data-testid="list-scroll">{children}</div>
12
+ {foot}
13
+ </div>
14
+ );
15
+ }
@@ -0,0 +1,5 @@
1
+ import React from "react";
2
+
3
+ export default function Loading({ text = "Loading…" }) {
4
+ return <div className="py-6 text-sm text-neutral-400">{text}</div>;
5
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+
3
+ /** A link to a NavTarget without a router. `href` (from the host's `hrefFor`) keeps open-in-new-tab and the status-bar URL;
4
+ * ("#" without one, so the link still takes focus); a plain left click is handed to `onNavigate` instead, as react-router's <Link> does (modified clicks go to the browser). */
5
+ export default function NavLink({ target, hrefFor, onNavigate, children, ...rest }) {
6
+ const click = (e) => {
7
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
8
+ if (!onNavigate) return;
9
+ e.preventDefault();
10
+ onNavigate(target);
11
+ };
12
+ return <a href={hrefFor ? hrefFor(target) : "#"} onClick={click} {...rest}>{children}</a>;
13
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+
3
+ export default function PageHeader({ eyebrow, title, children }) {
4
+ return (
5
+ <header className="mb-10 shrink-0">
6
+ {eyebrow && <div className="mb-1 text-xs font-medium uppercase tracking-wider text-neutral-400">{eyebrow}</div>}
7
+ <h1 className="text-2xl font-semibold tracking-tight text-neutral-900">{title}</h1>
8
+ {children && <div className="mt-2 text-neutral-500">{children}</div>}
9
+ </header>
10
+ );
11
+ }
@@ -0,0 +1,21 @@
1
+ import React, { useState } from "react";
2
+ import { Search } from "lucide-react";
3
+
4
+ /** Submits a search: `onNavigate({kind: "search", query, workspace})`. `workspace` scopes it to one workspace; without it the
5
+ * search page searches every granted one. No router here: the host turns the target into a route. */
6
+ export default function SearchBox({ initial = "", autoFocus, workspace, placeholder, big, onNavigate }) {
7
+ const [q, setQ] = useState(initial);
8
+ const submit = (e) => {
9
+ e.preventDefault();
10
+ if (!q.trim()) return;
11
+ onNavigate?.({ kind: "search", query: q.trim(), ...(workspace ? { workspace } : {}) });
12
+ };
13
+ return (
14
+ <form role="search" onSubmit={submit} className="relative">
15
+ <Search className={`pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-neutral-400 ${big ? "h-5 w-5" : "h-4 w-4"}`} />
16
+ <input value={q} onChange={(e) => setQ(e.target.value)} autoFocus={autoFocus} aria-label="Search"
17
+ placeholder={placeholder || "Search titles, summaries, paths, feature IDs…"}
18
+ className={`w-full rounded-lg border border-neutral-200 bg-ground pr-3 text-ink outline-none transition placeholder:text-neutral-400 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/25 ${big ? "h-12 pl-11 text-[15px]" : "h-10 pl-10 text-sm"}`} />
19
+ </form>
20
+ );
21
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import Section from "./Section";
3
+
4
+ export default function SearchGroup({ title, total, items }) {
5
+ if (!items?.length) return null;
6
+ return (
7
+ <Section title={title} meta={total != null && `${total.toLocaleString()} matches`}>
8
+ {items}
9
+ </Section>
10
+ );
11
+ }
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+
3
+ export default function Section({ title, meta, children, className = "" }) {
4
+ return (
5
+ <section className={`mt-12 first:mt-0 ${className}`}>
6
+ <div className="mb-3 flex items-baseline justify-between border-b border-neutral-100 pb-2">
7
+ <h2 className="text-[13px] font-semibold uppercase tracking-wider text-neutral-500">{title}</h2>
8
+ {meta && <span className="text-xs text-neutral-400">{meta}</span>}
9
+ </div>
10
+ {children}
11
+ </section>
12
+ );
13
+ }
@@ -0,0 +1,20 @@
1
+ import React from "react";
2
+
3
+ const GREEN = "bg-emerald-50 text-emerald-700 border-emerald-200";
4
+ const BLUE = "bg-blue-50 text-blue-700 border-blue-200";
5
+ const AMBER = "bg-amber-50 text-amber-700 border-amber-200";
6
+ const ORANGE = "bg-orange-50 text-orange-700 border-orange-200";
7
+ const RED = "bg-red-50 text-red-700 border-red-200";
8
+ const GREY = "bg-neutral-50 text-neutral-500 border-neutral-200";
9
+
10
+ const STYLES = {
11
+ active: GREEN, done: BLUE, superseded: AMBER, stale: ORANGE, unknown: GREY,
12
+ complete: GREEN, in_progress: BLUE, partial: ORANGE, planned: GREY, open: AMBER, killed: RED,
13
+ junk: RED, archived: GREY, new: BLUE, triaged: AMBER, adopted: GREEN, rejected: RED,
14
+ accepted: GREEN, proposed: AMBER, logged: GREY, ok: GREEN, error: RED,
15
+ };
16
+
17
+ export default function StatusChip({ status = "unknown" }) {
18
+ const key = String(status || "unknown").toLowerCase();
19
+ return <span className={`inline-flex whitespace-nowrap rounded-full border px-2 py-0.5 text-[11px] font-medium ${STYLES[key] || GREY}`}>{key.replace("_", " ")}</span>;
20
+ }
@@ -0,0 +1,9 @@
1
+ import React from "react";
2
+
3
+ export default function TypeBadge({ type }) {
4
+ return (
5
+ <span data-testid="type-badge" className="inline-flex min-w-[4.25rem] max-w-full justify-center truncate rounded border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 font-mono text-[10.5px] uppercase tracking-wide text-neutral-600">
6
+ {type || "—"}
7
+ </span>
8
+ );
9
+ }