diff --git a/module/contracts/behavior.codefly.json b/module/contracts/behavior.codefly.json index 93858a835..bfdc7b318 100644 --- a/module/contracts/behavior.codefly.json +++ b/module/contracts/behavior.codefly.json @@ -282,6 +282,7 @@ "@codefly-dev/ui/skin", "@codefly-dev/ui/table", "@codefly-dev/ui/content", + "@codefly-dev/ui/board", "@codefly-dev/ui/plugin-host", "@codefly-dev/ui/plugin-host/runtime", "@codefly-dev/ui/plugin-host/ui", diff --git a/module/services/frontend/code/UI_MIGRATION.md b/module/services/frontend/code/UI_MIGRATION.md index ae033b7ab..e4145faaa 100644 --- a/module/services/frontend/code/UI_MIGRATION.md +++ b/module/services/frontend/code/UI_MIGRATION.md @@ -43,6 +43,10 @@ and exports the injected TanStack `DataTable`. It emits declarations using the existing package build. The host shares every exported UI subpath, including table, as a versioned federation singleton; the exported-subpath guard prevents future omissions. Generic remotes must share those same exact subpath keys. +`@codefly-dev/ui/board` joined them at 0.12.1 on the same terms +([#1010](https://github.com/codefly-dev/module-saas-starter/issues/1010)): +composite rank 3, layout below it, and a declared key in the host's shared scope +and in the behavioural contract that is checked against it. The existing `dashboard` tier also owns `MetricAreaChart`, `MetricLineChart`, `MetricBarChart`, metric tiles, metric state and provenance, and Sparkline. diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index a75f148f4..5d99b530a 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -14,8 +14,8 @@ "@base-ui/react": "^1.3.0", "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-sdk": "0.3.15", - "@codefly-dev/saas-ui": "0.12.0", - "@codefly-dev/ui": "0.12.0", + "@codefly-dev/saas-ui": "0.12.1", + "@codefly-dev/ui": "0.12.1", "@codefly/saas-plugin-contract": "2.4.0", "@codefly/saas-plugin-manifest": "0.1.0", "@codefly/saas-plugin-react": "0.4.1", @@ -15266,7 +15266,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.12.0", + "version": "0.12.1", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15422,7 +15422,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.12.0", + "version": "0.12.1", "license": "Apache-2.0", "devDependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index 46019785b..e4d44f8d4 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -37,8 +37,8 @@ "@base-ui/react": "^1.3.0", "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-sdk": "0.3.15", - "@codefly-dev/saas-ui": "0.12.0", - "@codefly-dev/ui": "0.12.0", + "@codefly-dev/saas-ui": "0.12.1", + "@codefly-dev/ui": "0.12.1", "@codefly/saas-plugin-contract": "2.4.0", "@codefly/saas-plugin-manifest": "0.1.0", "@codefly/saas-plugin-react": "0.4.1", diff --git a/module/services/frontend/code/packages/codefly-ui/ARCHITECTURE.md b/module/services/frontend/code/packages/codefly-ui/ARCHITECTURE.md index ce181ca2e..e25bb9f3e 100644 --- a/module/services/frontend/code/packages/codefly-ui/ARCHITECTURE.md +++ b/module/services/frontend/code/packages/codefly-ui/ARCHITECTURE.md @@ -17,6 +17,7 @@ content Markdown · JsonView · CodeBlock · TextBlock → Content (text dashboard composes layout + charts chat composes layout atoms + SDK stream hook table/form composes layout atoms (DataTable takes an injected TanStack table) +board composes layout atoms (one collection, in columns by a field) ↓ Page.tsx full freedom: composes any of the above + solution-specific code ``` @@ -24,8 +25,9 @@ Page.tsx full freedom: composes any of the above + solution-specific code `content` sits at the rank of `charts`: it composes layout atoms, and the composites compose it (`` renders a markdown answer through it). `charts` currently lives inside `dashboard/` and is extracted to its own tier by -#403. `chat`, `table`, and `form` are composite tiers that sit at the same rank -as `dashboard` (they compose `layout`/`charts`, not each other). `plugin-host` +#403. `chat`, `table`, `board`, and `form` are composite tiers that sit at the +same rank as `dashboard` (they compose `layout`/`charts`, not each other). +`plugin-host` is not a presentational tier — it is the host-facing plugin-runtime surface and sits outside this stack. diff --git a/module/services/frontend/code/packages/codefly-ui/CATALOG.md b/module/services/frontend/code/packages/codefly-ui/CATALOG.md index 19614ae75..a6057739a 100644 --- a/module/services/frontend/code/packages/codefly-ui/CATALOG.md +++ b/module/services/frontend/code/packages/codefly-ui/CATALOG.md @@ -10,10 +10,11 @@ not evidence that all primitives, stories or production callers had migrated. | Tier | Contract | | --- | --- | | `layout` (rank 1) | Native and Base UI controls, compound CardRoot/TabsRoot, data-in Card/Tabs, SegmentedControl, Pagination, Field, table toolbar and empty state, page layout, status tones (Badge, Chip/ChipGroup, Banner), List/ListItem and DescriptionList, feedback, responsive Sidebar and Toaster | -| `content` (rank 2) | Markdown (GFM, no raw HTML, safe links, images opt-in), bounded collapsible JSON tree, code with lazily-loaded highlighting, whitespace-preserving text, and the `Content` switch over them with `auto` detection and an `inline` one-line variant | +| `content` (rank 2) | Markdown (GFM, no raw HTML, safe links including a caller-resolved one, images opt-in, optional source-byte offsets for an annotation layer), bounded collapsible JSON tree, code with lazily-loaded highlighting, whitespace-preserving text, and the `Content` switch over them with `auto` detection and an `inline` one-line variant | | `dashboard` (rank 3) | Declarative dashboard, point charts, multi-series metric charts, tiles, provenance and sparklines | | `chat` (rank 3) | Resolved messages and injected send action | | `table` (rank 3) | DataTable driven by an injected TanStack table instance | +| `board` (rank 3) | Board: a collection in columns by a field, dragged or moved from a menu, committing nothing | Composite tiers compose layout; they do not import sibling composites. No tier fetches data, reads host authentication or imports private host aliases. Host CSS diff --git a/module/services/frontend/code/packages/codefly-ui/README.md b/module/services/frontend/code/packages/codefly-ui/README.md index 62f8c1cc0..f286f7269 100644 --- a/module/services/frontend/code/packages/codefly-ui/README.md +++ b/module/services/frontend/code/packages/codefly-ui/README.md @@ -50,7 +50,14 @@ and the v1 subset — is [CATALOG.md](./CATALOG.md). `TextBlock` — are exported too. Untrusted by default: no raw HTML, links only for http/https/mailto with `rel="noopener noreferrer"`, images off unless `allowImages`, and no `innerHTML` anywhere; colour only through the host's - token utilities. + token utilities. `` also marks what it rendered with the bytes it + came from (`sourceOffsets`) and lets the caller say where a link actually goes + (`resolveLink`) — see *A rendered document an annotation layer can read*. + +- **Board** (`@codefly-dev/ui/board`) — one collection in columns, one per value + of a field, whose cards a reader drags from one column to another or moves from + a menu. It commits nothing: a move is reported and the consumer confirms, + writes or refuses it. See *A board commits no move*. ### Status, card headers, lists and empty states @@ -102,6 +109,7 @@ solution measured where it had to reach past the kit. | `@codefly-dev/ui/dashboard` | `Dashboard`, charts, `fromDashboardData` (React-only) | | `@codefly-dev/ui/chat` | `Chat` (React-only) | | `@codefly-dev/ui/content` | `Content`, `Markdown`, `JsonView`, `CodeBlock`, `TextBlock` (React-only) | +| `@codefly-dev/ui/board` | `Board` (React-only) | | `@codefly-dev/ui/type-slots.css` | The generated type-slot and control-rung utilities | | `@codefly-dev/ui/theme.css` | The token layer: token → utility, light/dark binding, custom variants (Tailwind source) | | `@codefly-dev/ui/preview.css` | The kit compiled with the default skin, for previews only — see below | @@ -114,7 +122,7 @@ second copy would split that context and break `usePluginRuntime` in a remote. The two plugin peers are **optional** (`peerDependenciesMeta`): only `.`, `./plugin-host`, and `./skin` touch them, and the host supplies them. The -`./layout`, `./dashboard`, `./chat` and `./content` subpaths reference neither, so a consumer +`./layout`, `./dashboard`, `./chat`, `./content` and `./board` subpaths reference neither, so a consumer of just those subpaths installs the kit without pulling the host-internal plugin packages. `./layout` does pull the primitives' public runtime deps (`@base-ui/react`, `lucide-react`, `class-variance-authority`, `clsx`, @@ -123,6 +131,13 @@ from the public registry with no extra config. ## Consuming from a solution +**A remote's declared range has to admit the version that has what it uses.** The +kit is versioned in 0.x and an additive release is a patch, so `^0.12.0` is +satisfied by 0.12.0 — which carries neither `Markdown`'s `sourceOffsets` nor +`@codefly-dev/ui/board`. A remote using either declares `^0.12.1`. (Registration +checks the declared range against the version the host publishes, so a range that +is too loose fails at runtime rather than at install.) + A solution fe-remote imports `@codefly-dev/ui/layout` + `@codefly-dev/ui/dashboard` and shares them as Module-Federation singletons served by the host. Because the plugin peers are optional, the solution only needs an `.npmrc` pointing the @@ -273,6 +288,118 @@ and missing everywhere else. `DescriptionList`'s `grid-cols-[minmax(0,max-content)_minmax(0,1fr)]` is fine here for that reason. A kit element meant to render from a remote carries its structure inline (a `style` for layout) and takes only colour and type from the tokens. +`src/__tests__/remote-safe-structure.test.ts` holds the components written +against that rule to it, and names the ones it does not yet cover. + +## A rendered document an annotation layer can read + +A reader selects words in a rendered document and writes a comment about them. +Mapping that selection back to the document is not a matter of counting +characters on screen, because **rendered text is not its source**: markdown drops +`**`, a heading loses its `#`, a list gains a bullet nobody wrote. So the +renderer says which bytes it rendered, and the reading layer never has to guess. + +```tsx +const front = version.indexOf("# "); // the body, without its front matter + resolve(href)} // where a link in this corpus goes +> + {version.slice(front)} + +``` + +| Attribute | On | Means | +| --- | --- | --- | +| `data-source-start`, `data-source-end` | every block, and a `` around every text run | the element's text was rendered from bytes `[start, end)` of the version | +| `data-source-exact="false"` | anything markdown rewrote | the text is not those bytes verbatim, so a selection inside it widens to the whole element | +| `data-source-ignore` | a code block's copy control | chrome the renderer added; it maps to nothing | + +This is the contract a composed module's annotation kit already reads through +its text-range locator (its own `src/anchors/source-map.ts`); this kit matches it +rather than inventing one. + +- **Offsets are UTF-8 bytes, half-open, counted in the version** — not string + indices. `é` is one index and two bytes, an emoji two indices and four, so a + document of plain ASCII passes a renderer that forgot the difference. + `sourceStart` is the byte offset of what you passed inside the whole version, + so a body rendered without its front matter still names the version's bytes. +- **A plain run is byte-exact; everything else says so.** Exactness is *measured* + — each element's and each run's text is compared with the source it claims — + rather than listed, so a construct nobody anticipated is marked inexact instead + of lying. A fenced block is the shape in miniature: the `
` carries the
+  whole fence and is never exact, while the `` inside carries the body
+  alone and is, so a reader comments on the block as a whole and selects inside
+  it character for character.
+- **The two source-rewriting options are off.** `references` protects a `[n]`
+  marker by rewriting the source before it is parsed, which would move every
+  offset after it, so it is not applied under `sourceOffsets`. `lineBreaks` is
+  applied: it splits a run rather than the source, and each piece keeps its own
+  bytes.
+- **Chrome the source did not write** — a GFM footnote's generated heading, its
+  `↩` back-reference — has no range of its own and maps to its nearest marked
+  ancestor as a whole.
+
+`resolveLink` answers the other half. A link in a corpus is usually a path in a
+repository (`../decisions/x.md#why`), and only the caller knows where that goes:
+it returns `{ href, external }` for a destination it knows, `{ open }` to
+navigate inside the product with no URL at all, `{ unavailable }` for a target it
+cannot show, or `undefined` to leave the kit's own rule in place. **A resolved
+href is held to exactly the same allowlist as one the content wrote** — http,
+https or mailto, no credentials in the authority — so no resolver can turn
+`javascript:` into a link.
+
+That allowlist refuses a **relative** result too, so `{ href: "#p=guide" }` or
+`{ href: "/pages/guide" }` renders as inert text rather than a link: a resolver
+returning an in-product route must give `{ open }` (the kit navigates nowhere by
+itself) or an absolute URL. The one exception is `linkBase`, which resolves a
+relative result against the caller's own trusted location first and then applies
+the same rules.
+
+## A board commits no move
+
+```tsx
+import { Board } from "@codefly-dev/ui/board";
+
+ request.stage}       // the field it groups by
+  idOf={(request) => request.ref}
+  labelOf={(request) => request.name}         // what to call one, in a sentence
+  renderCard={(request) => }
+  onOpen={(request) => open(request)}
+  onMove={(request, column) => confirmThenWrite(request, column)}
+  canMove={(request, column) => mayMove(request, column)}  // optional
+  searchText={(request) => `${request.name} ${request.owner}`}
+/>
+```
+
+- **The board changes nothing.** A drop, or a choice in a card's Move menu, calls
+  `onMove(item, column)` and stops there. The consumer confirms it with the
+  reader, writes it, or refuses it, and passes `items` back with the new column. A
+  board that moved the card itself would be showing a state the server never
+  agreed to, and would have to take it back when the write failed.
+- **Dragging is never the only way.** A pointer drag is a gesture a keyboard
+  cannot make and a screen reader cannot see, so every card that may move carries
+  a **Move** menu listing the columns it may go to, beside the control that opens
+  it. (This is where `SortableGrid` asks its caller for the keyboard
+  alternative — a board knows what the alternative is, so it ships it.) The
+  screen-reader instructions name that menu rather than the arrow keys dnd-kit
+  assumes.
+- **It knows nothing about what a column means.** No status, no workflow, no
+  vocabulary: `columnOf` names the field and `columns` names its values, so two
+  columns or five cost the same. `canMove` narrows which moves exist at all — a
+  column it refuses accepts no drop and is absent from the Move menu, and a card
+  with nowhere to go does not drag.
+- **Search is the consumer's.** `searchText` says what a search matches an item
+  against; without it there is no box, because only the consumer knows which
+  fields are worth searching.
+- Structure is inline (see *Structure travels inline when the host does not
+  compile you*), which `src/__tests__/remote-safe-structure.test.ts` holds it to:
+  a board renders from a remote, where only what the host already compiled exists.
 
 ## A loading indicator never flashes
 
diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json
index 4be7fa5fd..73f1dd4cb 100644
--- a/module/services/frontend/code/packages/codefly-ui/package.json
+++ b/module/services/frontend/code/packages/codefly-ui/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@codefly-dev/ui",
-	"version": "0.12.0",
+	"version": "0.12.1",
 	"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
 	"repository": {
 		"type": "git",
@@ -69,6 +69,11 @@
 			"types": "./dist/content/index.d.ts",
 			"import": "./dist/content/index.js",
 			"default": "./dist/content/index.js"
+		},
+		"./board": {
+			"types": "./dist/board/index.d.ts",
+			"import": "./dist/board/index.js",
+			"default": "./dist/board/index.js"
 		}
 	},
 	"scripts": {
diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/architecture.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/architecture.test.ts
index ff4cba71c..af3a966cd 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/__tests__/architecture.test.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/architecture.test.ts
@@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest";
 // same rank as `dashboard` — they compose the atoms below, never each other.
 // `content` (markdown, JSON, code, text) sits beside `charts`: it composes
 // layout atoms, and the composites (chat renders a markdown answer) compose it.
+// `board` is a composite like `table`: one collection, laid out by a field.
 // `plugin-host` and the root entry are deliberately absent: they are the
 // host-facing surface, not part of the presentational turtle stack.
 const TIER_RANK: Record = {
@@ -25,6 +26,7 @@ const TIER_RANK: Record = {
 	dashboard: 3,
 	chat: 3,
 	table: 3,
+	board: 3,
 	form: 3,
 };
 
diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/package-contract.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/package-contract.test.ts
index 68aae35fb..f0aabfca5 100644
--- a/module/services/frontend/code/packages/codefly-ui/src/__tests__/package-contract.test.ts
+++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/package-contract.test.ts
@@ -72,6 +72,7 @@ describe("@codefly-dev/ui public subpaths", () => {
 		"./chat",
 		"./layout",
 		"./content",
+		"./board",
 	]) {
 		it(`exports ${subpath} to a typed dist entry`, () => {
 			const entry = exportsMap[subpath];
@@ -129,7 +130,7 @@ describe("@codefly-dev/ui peer-free solution surface", () => {
 // checks above cannot see. Guard the source directly.
 describe("@codefly-dev/ui solution subpaths stay plugin-free", () => {
 	const srcDir = codeflyUiSrcDir();
-	for (const subpath of ["layout", "dashboard", "chat", "content"]) {
+	for (const subpath of ["layout", "dashboard", "chat", "content", "board"]) {
 		it(`./${subpath} imports no @codefly/saas-plugin-* package`, () => {
 			for (const file of sourceFiles(join(srcDir, subpath))) {
 				const source = readFileSync(file, "utf8");
diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/remote-safe-structure.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/remote-safe-structure.test.ts
new file mode 100644
index 000000000..787d390c0
--- /dev/null
+++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/remote-safe-structure.test.ts
@@ -0,0 +1,152 @@
+import { existsSync, readFileSync } from "node:fs";
+import { join, resolve } from "node:path";
+import { describe, expect, it } from "vitest";
+
+// A component rendered from a solution REMOTE is laid out by whatever the host
+// already compiled, and nothing else.
+//
+// The host builds one stylesheet from its own sources — this kit and
+// `@codefly-dev/saas-ui` — so a class exists in the page only because something
+// the host compiled wrote it. An ordinary token utility (`bg-muted`, `p-3`,
+// `type-card-title`) is written by the host's own components too, so it is
+// there. An arbitrary value (`grid-cols-[minmax(0,1fr)_18rem]`) and a
+// breakpoint variant (`md:absolute`) exist only because ONE file wrote them, and
+// the failure when they are missing is silent: jsdom lays nothing out, so the
+// kit's own tests pass, and a reviewer sees the component correctly in a
+// preview that does compile them. Measured once already, on a composed module's
+// kit: a margin grid that computed to one column and put every comment card
+// 8,000px below the text it was about (the composing workspace's handbook
+// proposal "A module kit carries its own structure", 4 October 2026).
+//
+// So a component on this list carries its structure itself — an inline `style`
+// for display, position, grid and flex templates, sizes and insets — and takes
+// only colour, type and spacing from the tokens.
+//
+// The list is what has been AUDITED, and it grows. It is not the whole kit: the
+// primitives in `layout/` predate the rule and carry arbitrary variants
+// (`data-[side=bottom]:`, `rounded-[var(--appearance-…)]`), and `content/`
+// carries three. Widening this to the whole kit with a shrinking baseline is the
+// composing workspace's handbook proposal's ask and waits for it to be
+// accepted; pinning the
+// components written against the rule keeps them from drifting back meanwhile.
+const REMOTE_SAFE = ["board/board.tsx"];
+
+// String literals only, so this file's own explanation of what it refuses — and
+// a component's header comment — is prose rather than a breach. Three things this
+// gets right that are easy to get wrong:
+//
+//   - **Template literals count.** A class list written in backticks is still a
+//     class list; a guard that reads only `"` and `'` lets `md:flex` through the
+//     moment someone interpolates a variable into the list.
+//   - Every literal is scanned, not only the ones that look like a class list.
+//     An arbitrary value holds `(`, `,` and `_`, which the raw-type guard's
+//     "looks like a class" filter drops — so that filter would have missed
+//     `grid-cols-[minmax(0,1fr)_18rem]`, the exact failure this exists for.
+//   - `${…}` holes are blanked rather than read, so an interpolated CSS length
+//     inside an inline style is not mistaken for a utility.
+function stringLiterals(source: string): string[] {
+	const out: string[] = [];
+	for (const match of source.matchAll(/"([^"\n]*)"|'([^'\n]*)'/g)) {
+		out.push(match[1] ?? match[2] ?? "");
+	}
+	for (const match of source.matchAll(/`((?:[^`\\]|\\.)*)`/g)) {
+		out.push((match[1] ?? "").replace(/\$\{[^}]*\}/g, " "));
+	}
+	return out;
+}
+
+// Any class carrying a bracketed group, wherever it sits: an arbitrary value
+// (`h-[var(--x)]`), an arbitrary variant (`data-[state=open]:bg-muted`), or a
+// bare property (`[overflow-wrap:anywhere]`). Matching the whole token rather
+// than a shape keeps a spelling nobody anticipated from passing.
+const ARBITRARY = /(?:^|[\s"'`])([^\s"'`]*\[[^\]]+\][^\s"'`]*)/g;
+const BREAKPOINT =
+	/(?:^|[\s"'`])((?:sm|md|lg|xl|2xl|min|max|container):[^\s"'`]+)/g;
+
+export function hostCompiledOnly(source: string): string[] {
+	const found: string[] = [];
+	for (const value of stringLiterals(source)) {
+		for (const match of value.matchAll(ARBITRARY))
+			found.push(match[1] as string);
+		for (const match of value.matchAll(BREAKPOINT))
+			found.push(match[1] as string);
+	}
+	return found;
+}
+
+function kitSrcDir(): string {
+	for (const candidate of ["src", "packages/codefly-ui/src"]) {
+		const absolute = resolve(process.cwd(), candidate);
+		if (existsSync(join(absolute, "index.ts"))) return absolute;
+	}
+	throw new Error("could not locate the @codefly-dev/ui src directory");
+}
+
+const srcDir = kitSrcDir();
+
+describe("a kit component meant to render from a remote carries its own structure", () => {
+	for (const name of REMOTE_SAFE) {
+		it(`${name} needs no class the host did not already compile`, () => {
+			const file = join(srcDir, name);
+			expect(
+				existsSync(file),
+				`${name} is on the list but not in the tree`,
+			).toBe(true);
+			const found = hostCompiledOnly(readFileSync(file, "utf8"));
+			expect(
+				found,
+				`${name} writes ${[...new Set(found)].join(", ")} — a class that exists only because this file wrote it. Put the structure in an inline style; keep colour, type and spacing on the tokens.`,
+			).toEqual([]);
+		});
+	}
+
+	it("places the board's columns with an inline style, not a class", () => {
+		const source = readFileSync(join(srcDir, "board", "board.tsx"), "utf8");
+		// The one structural decision a class could not carry here: how many
+		// columns fit before the board wraps.
+		expect(source).toContain("gridTemplateColumns");
+		expect(source).not.toMatch(/grid-cols-/);
+	});
+});
+
+// The guard is only worth having if it fires. A green run must mean "nothing a
+// remote would lose", never "the detector was quietly disarmed".
+describe("host-compiled-only detector (self-test)", () => {
+	it.each([
+		[
+			'
', + "grid-cols-[minmax(0,1fr)_18rem]", + ], + [ + '
', + "h-[var(--anchored-margin)]", + ], + ['
', "md:absolute"], + [ + '
', + "sm:group-hover/code:opacity-100", + ], + [ + '
', + "[overflow-wrap:anywhere]", + ], + [ + '
', + "data-[side=bottom]:slide-in-from-top-2", + ], + // A class list in backticks, with a hole in it: the form that slipped past + // the first version of this guard. + ["const x = `md:flex ${gap}`;", "md:flex"], + ])("flags %s", (source, utility) => { + expect(hostCompiledOnly(source)).toContain(utility); + }); + + it.each([ + '
', + '
', + '
', + "style={{ gridTemplateColumns: `repeat(auto-fit, minmax(16rem, 1fr))` }}", + ])("allows %s", (source) => { + expect(hostCompiledOnly(source)).toEqual([]); + }); +}); diff --git a/module/services/frontend/code/packages/codefly-ui/src/board/__tests__/board.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/board/__tests__/board.test.tsx new file mode 100644 index 000000000..cc2868b5a --- /dev/null +++ b/module/services/frontend/code/packages/codefly-ui/src/board/__tests__/board.test.tsx @@ -0,0 +1,364 @@ +// @vitest-environment happy-dom +import { + act, + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Board, type BoardColumn } from "../board.js"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +interface Row { + ref: string; + name: string; + stage: string; + owner: string; +} + +const COLUMNS: BoardColumn[] = [ + { id: "open", label: "Open", empty: "Nothing open" }, + { id: "accepted", label: "Accepted" }, + { id: "rejected", label: "Rejected" }, +]; + +const ROWS: Row[] = [ + { ref: "a", name: "Ask one", stage: "open", owner: "jane" }, + { ref: "b", name: "Ask two", stage: "open", owner: "sam" }, + { ref: "c", name: "Settled thing", stage: "accepted", owner: "jane" }, +]; + +function board(props: Partial>[0]> = {}) { + return render( + + items={ROWS} + columns={COLUMNS} + columnOf={(row) => row.stage} + idOf={(row) => row.ref} + labelOf={(row) => row.name} + renderCard={(row) => {row.name}} + {...props} + />, + ); +} + +const column = (label: string) => + screen.getByRole("region", { name: label }) ?? + (screen.getByLabelText(label) as HTMLElement); + +/** base-ui opens its menu through effects; let them flush. */ +async function open(trigger: HTMLElement) { + await act(async () => { + fireEvent.click(trigger); + }); +} + +// happy-dom lays nothing out, so every rect is zero and dnd-kit's pointer +// collision finds no column whatever the drag does — a drag test would pass +// against a board that never moved anything. So the columns and cards are given +// a real geometry: three 300px columns side by side, each card at the top of its +// own. This is the layout the assertions below are about. +const RECTS: Record = { + "column:open": [0, 0, 300, 400], + "column:accepted": [320, 0, 300, 400], + "column:rejected": [640, 0, 300, 400], + "card:a": [10, 40, 280, 80], + "card:b": [10, 130, 280, 80], + "card:c": [330, 40, 280, 80], +}; + +function layOut() { + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation( + function (this: Element) { + const key = + (this.getAttribute?.("data-board-column") !== null + ? `column:${this.getAttribute("data-board-column")}` + : undefined) ?? + (this.getAttribute?.("data-board-card") !== null + ? `card:${this.getAttribute("data-board-card")}` + : undefined); + const [x, y, width, height] = RECTS[key ?? ""] ?? [0, 0, 1000, 600]; + return { + x, + y, + left: x, + top: y, + width, + height, + right: x + width, + bottom: y + height, + toJSON: () => ({}), + } as DOMRect; + }, + ); +} + +/** A pointer drag from the card to a point on the page, as a mouse makes it. */ +async function dragCard(id: string, to: [number, number]) { + const card = document.querySelector( + `[data-board-card="${id}"]`, + ) as HTMLElement; + const [x, y] = RECTS[`card:${id}`] ?? [0, 0]; + const from: [number, number] = [x + 20, y + 20]; + const move = async (at: [number, number]) => { + await act(async () => { + document.dispatchEvent( + new MouseEvent("mousemove", { + bubbles: true, + clientX: at[0], + clientY: at[1], + }), + ); + }); + }; + await act(async () => { + card.dispatchEvent( + new MouseEvent("mousedown", { + bubbles: true, + button: 0, + clientX: from[0], + clientY: from[1], + }), + ); + }); + // The sensor waits 5px before a drag begins, so a click on a control inside a + // card still lands. + await move([from[0] + 10, from[1] + 10]); + await move(to); + await act(async () => { + document.dispatchEvent( + new MouseEvent("mouseup", { + bubbles: true, + clientX: to[0], + clientY: to[1], + }), + ); + }); +} + +describe("Board groups a collection by a field", () => { + it("puts every item on exactly one column, and counts it there", () => { + board(); + expect(within(column("Open")).getAllByRole("listitem")).toHaveLength(2); + expect(within(column("Open")).getByText("2")).toBeTruthy(); + expect(within(column("Accepted")).getByText("1")).toBeTruthy(); + expect(screen.getAllByText(/^Ask |^Settled/)).toHaveLength(3); + }); + + it("says what an empty column would hold, in the consumer's words", () => { + board({ items: [] }); + expect(within(column("Open")).getByText("Nothing open")).toBeTruthy(); + // A column that gave no words still says something rather than nothing. + expect(within(column("Rejected")).getByText("Nothing here")).toBeTruthy(); + }); + + it("shows no search box without something to search", () => { + board(); + expect(screen.queryByRole("searchbox")).toBeNull(); + }); + + it("searches what the consumer says is searchable, and nothing else", () => { + board({ + searchText: (row) => `${row.name} ${row.owner}`, + searchLabel: "Search by name or owner", + }); + fireEvent.change( + screen.getByRole("searchbox", { name: "Search by name or owner" }), + { target: { value: "sam" } }, + ); + expect(screen.getByText("Ask two")).toBeTruthy(); + expect(screen.queryByText("Ask one")).toBeNull(); + // A field the consumer did not offer is not searched. + fireEvent.change(screen.getByRole("searchbox"), { + target: { value: "accepted" }, + }); + expect(screen.queryByText("Settled thing")).toBeNull(); + }); + + it("orders cards inside a column the way the consumer asks", () => { + board({ compare: (a, b) => a.name.localeCompare(b.name) }); + const names = within(column("Open")) + .getAllByRole("listitem") + .map((row) => row.textContent); + expect(names[0]).toContain("Ask one"); + }); +}); + +describe("a card is reachable without a pointer", () => { + it("opens from the keyboard, with a name that says which card", () => { + const onOpen = vi.fn(); + board({ onOpen }); + const first = screen.getByRole("button", { name: "Open Ask one" }); + first.focus(); + expect(document.activeElement).toBe(first); + fireEvent.keyDown(first, { key: "Enter" }); + fireEvent.click(first); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onOpen.mock.calls[0]?.[0]).toMatchObject({ ref: "a" }); + }); + + it("is not a control at all when there is nothing to open", () => { + board(); + expect(screen.queryByRole("button", { name: /^Open / })).toBeNull(); + expect(screen.getByText("Ask one")).toBeTruthy(); + }); + + it("offers a move to every other column, by name", async () => { + const onMove = vi.fn(); + board({ onMove }); + await open(screen.getByRole("button", { name: "Move Ask one" })); + const offered = screen + .getAllByRole("menuitem") + .map((item) => item.textContent); + // Its own column is not a move. + expect(offered).toEqual(["Accepted", "Rejected"]); + }); + + it("reports a move and changes nothing itself", async () => { + const onMove = vi.fn(); + board({ onMove }); + await open(screen.getByRole("button", { name: "Move Ask one" })); + await act(async () => { + fireEvent.click(screen.getByRole("menuitem", { name: "Accepted" })); + }); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove.mock.calls[0]?.[0]).toMatchObject({ ref: "a" }); + expect(onMove.mock.calls[0]?.[1]).toMatchObject({ id: "accepted" }); + // The consumer owns the write: the card is still where it was. + expect(within(column("Open")).getByText("Ask one")).toBeTruthy(); + expect(within(column("Accepted")).queryByText("Ask one")).toBeNull(); + }); +}); + +describe("a board the consumer did not open to moves does not offer them", () => { + it("carries no move control without onMove", () => { + board(); + expect(screen.queryByRole("button", { name: /^Move / })).toBeNull(); + }); + + it("drops a column canMove refuses, and the card with no target at all", async () => { + const onMove = vi.fn(); + board({ + onMove, + canMove: (row, target) => + row.owner === "jane" && target.id === "accepted", + }); + // "Ask two" is sam's: no column accepts it, so it offers no move. + expect(screen.queryByRole("button", { name: "Move Ask two" })).toBeNull(); + await open(screen.getByRole("button", { name: "Move Ask one" })); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Accepted"]); + }); +}); + +describe("the board stays generic", () => { + it("works with two columns and a field that is not a status", () => { + const onMove = vi.fn(); + render( + + items={ROWS} + columns={[ + { id: "jane", label: "Jane" }, + { id: "sam", label: "Sam" }, + ]} + columnOf={(row) => row.owner} + idOf={(row) => row.ref} + labelOf={(row) => row.name} + renderCard={(row) => {row.name}} + onMove={onMove} + />, + ); + expect(within(column("Jane")).getAllByRole("listitem")).toHaveLength(2); + expect(within(column("Sam")).getAllByRole("listitem")).toHaveLength(1); + }); + + it("shows no card for a column it was not given", () => { + board({ columns: [COLUMNS[0] as BoardColumn] }); + expect(screen.getByText("Ask one")).toBeTruthy(); + expect(screen.queryByText("Settled thing")).toBeNull(); + }); +}); + +describe("dragging a card onto a column", () => { + it("reports the move and leaves the board exactly as it was", async () => { + layOut(); + const onMove = vi.fn(); + board({ onMove }); + await dragCard("a", [450, 200]); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove.mock.calls[0]?.[0]).toMatchObject({ ref: "a" }); + expect(onMove.mock.calls[0]?.[1]).toMatchObject({ id: "accepted" }); + expect(within(column("Open")).getByText("Ask one")).toBeTruthy(); + expect(within(column("Accepted")).queryByText("Ask one")).toBeNull(); + }); + + it("reports nothing for a drop back on the card's own column", async () => { + layOut(); + const onMove = vi.fn(); + board({ onMove }); + await dragCard("a", [150, 300]); + expect(onMove).not.toHaveBeenCalled(); + }); + + it("reports nothing for a drop on no column at all", async () => { + layOut(); + const onMove = vi.fn(); + board({ onMove }); + await dragCard("a", [900, 900]); + expect(onMove).not.toHaveBeenCalled(); + }); + + it("reports nothing for a drop on a column canMove refuses", async () => { + layOut(); + const onMove = vi.fn(); + board({ onMove, canMove: (_row, target) => target.id === "rejected" }); + await dragCard("a", [450, 200]); + expect(onMove).not.toHaveBeenCalled(); + await dragCard("a", [760, 200]); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove.mock.calls[0]?.[1]).toMatchObject({ id: "rejected" }); + }); + + it("reports nothing on a read-only board", async () => { + layOut(); + const onOpen = vi.fn(); + board({ onOpen }); + await dragCard("a", [450, 200]); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it("says what the drop would do, for a reader who cannot see the card", async () => { + layOut(); + board({ onMove: vi.fn() }); + await dragCard("a", [450, 200]); + expect(document.body.textContent).toContain( + "Ask one was dropped on Accepted.", + ); + }); + + it("tells a screen reader about the Move control, not about arrow keys it has no sensor for", () => { + board({ onMove: vi.fn() }); + const text = document.body.textContent ?? ""; + expect(text).toContain("also carries a Move control"); + expect(text).not.toContain("arrow keys"); + }); + + it("offers the drag cursor only on a card that may actually move", () => { + const { container } = board({ + onMove: vi.fn(), + canMove: (row) => row.ref === "a", + }); + const style = (id: string) => + (container.querySelector(`[data-board-card="${id}"]`) as HTMLElement) + .style.cursor; + expect(style("a")).toBe("grab"); + expect(style("b")).toBe(""); + }); +}); diff --git a/module/services/frontend/code/packages/codefly-ui/src/board/board.tsx b/module/services/frontend/code/packages/codefly-ui/src/board/board.tsx new file mode 100644 index 000000000..8d294d0de --- /dev/null +++ b/module/services/frontend/code/packages/codefly-ui/src/board/board.tsx @@ -0,0 +1,431 @@ +"use client"; + +/** + * Board — a collection in columns, one per value of a field, whose cards a + * reader drags from one column to another. + * + * **The board never commits a move.** A drop, or a choice in a card's Move + * menu, calls `onMove(item, column)` and nothing else: the consumer confirms it + * with the reader, writes it, refuses it, and passes `items` back with the new + * column. A board that moved the card itself would show a state the server never + * agreed to, and would have to undo it when the write failed. + * + * **Dragging is never the only way.** A pointer drag is a gesture a keyboard + * cannot make and a screen reader cannot see, so every card carries two real + * controls in reading order: one that opens it, and a **Move** menu listing the + * columns it may go to. That is the same answer `SortableGrid` documents — the + * grid has no keyboard sensor and asks its caller for the alternative — except + * that a board knows what the alternative is and ships it. + * + * Nothing here knows what a column means: no status, no workflow, no vocabulary. + * `columnOf` names the field, `columns` names its values, and a consumer that + * wants four states or two gets them the same way. + */ +import { + type Announcements, + DndContext, + DragOverlay, + MouseSensor, + pointerWithin, + type ScreenReaderInstructions, + TouchSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { type ReactNode, useId, useMemo, useState } from "react"; +import { Badge } from "../layout/badge.js"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../layout/dropdown-menu.js"; +import { Input } from "../layout/input.js"; +import { cn } from "./cn.js"; + +/** One column: a value of the field the board groups by, and what to call it. */ +export interface BoardColumn { + /** The value `columnOf` returns for the items that belong here. */ + id: string; + /** The column's heading, and its name in the Move menu. */ + label: string; + /** What the column says when nothing is in it. Default *Nothing here*. */ + empty?: ReactNode; +} + +export interface BoardProps { + /** Everything on the board; the column each lands in is `columnOf`'s answer. */ + items: readonly Item[]; + /** The columns, in the order they are shown. An item whose column is not here is not shown. */ + columns: readonly BoardColumn[]; + /** The field the board groups by, read off one item. */ + columnOf: (item: Item) => string; + /** A stable id for an item, unique across the board. */ + idOf: (item: Item) => string; + /** + * What to call one item: the accessible name of its open control and its Move + * menu, and how a drag is announced. Not the card's contents — the name a + * reader would use for it in a sentence. + */ + labelOf: (item: Item) => string; + /** + * The card's contents. With `onOpen` they sit inside the control that opens + * the card, so keep them non-interactive: a link or a button nested in a + * button is two targets announced as one. + */ + renderCard: (item: Item) => ReactNode; + /** Opening an item. Without it the card is text rather than a control. */ + onOpen?: (item: Item) => void; + /** + * An item was dropped on, or chosen to move to, another column. The board has + * changed nothing: confirm with the reader, write it, and pass `items` back. + * Without it the board is read-only and no card drags. + */ + onMove?: (item: Item, column: BoardColumn) => void; + /** + * Which moves are offered at all — a column a card may not enter accepts no + * drop and is absent from its Move menu. Default: every column but its own. + */ + canMove?: (item: Item, column: BoardColumn) => boolean; + /** + * The text the search box matches an item against. Without it there is no + * search box: only the consumer knows which of an item's fields are worth + * searching. + */ + searchText?: (item: Item) => string; + /** The search box's accessible name and placeholder. */ + searchLabel?: string; + /** How items are ordered inside a column. Default: the order of `items`. */ + compare?: (a: Item, b: Item) => number; + /** + * How narrow a column may get before the board wraps to another row. Default + * `16rem`. A CSS length: it is read as one, inline. + */ + columnWidth?: string; + className?: string; +} + +// A card is its own droppable-free draggable; the column it lands in is the +// droppable. So the pointer has to be over the column, not over a card in it, +// which is what makes dropping onto an empty column work. +const CARD = "card:"; +const COLUMN = "column:"; + +function Card({ + id, + item, + draggable, + children, +}: { + id: string; + item: Item; + draggable: boolean; + children: ReactNode; +}) { + const { setNodeRef, listeners, isDragging } = useDraggable({ + id: `${CARD}${id}`, + disabled: !draggable, + data: { item }, + }); + // dnd-kit's `attributes` are deliberately not spread: they would make the + // list item itself a focusable `role="button"`, announced beside the two real + // controls the card already carries, and a keyboard would land on a target + // that does nothing without a sensor behind it. + return ( +
  • + {children} +
  • + ); +} + +function Column({ + column, + count, + accepts, + children, +}: { + column: BoardColumn; + count: number; + accepts: boolean; + children: ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `${COLUMN}${column.id}`, + disabled: !accepts, + data: { column }, + }); + return ( +
    +
    +

    {column.label}

    + {count} +
    + {children} +
    + ); +} + +export function Board({ + items, + columns, + columnOf, + idOf, + labelOf, + renderCard, + onOpen, + onMove, + canMove, + searchText, + searchLabel = "Search", + compare, + columnWidth = "16rem", + className, +}: BoardProps) { + // A stable id keeps the ids dnd-kit writes into the page the same on the + // server and the client, as in SortableGrid. + const contextId = useId(); + const [query, setQuery] = useState(""); + const [dragged, setDragged] = useState(null); + + const shown = useMemo(() => { + const wanted = query.trim().toLowerCase(); + const matching = + wanted === "" || !searchText + ? [...items] + : items.filter((item) => + searchText(item).toLowerCase().includes(wanted), + ); + return compare ? matching.sort(compare) : matching; + }, [items, query, searchText, compare]); + + const byColumn = useMemo(() => { + const grouped = new Map( + columns.map((column) => [column.id, []]), + ); + for (const item of shown) grouped.get(columnOf(item))?.push(item); + return grouped; + }, [shown, columns, columnOf]); + + const movable = (item: Item, column: BoardColumn) => + !!onMove && + columnOf(item) !== column.id && + (canMove ? canMove(item, column) : true); + + const found = (id: string | null) => + id === null ? undefined : shown.find((item) => idOf(item) === id); + + // A column accepts a drop while something that may enter it is being dragged; + // with nothing in the air it accepts nothing, so a column is never announced + // as a target when there is nothing to put in it. + const accepting = (column: BoardColumn) => { + const item = found(dragged); + return item !== undefined && movable(item, column); + }; + + const name = (id: string) => { + const item = found(id.startsWith(CARD) ? id.slice(CARD.length) : id); + return item ? labelOf(item) : id; + }; + const columnName = (id: string) => + columns.find((column) => `${COLUMN}${column.id}` === id)?.label ?? id; + + const announcements: Announcements = { + onDragStart: ({ active }) => `Picked up ${name(String(active.id))}.`, + onDragOver: ({ active, over }) => + over + ? `${name(String(active.id))} will move to ${columnName(String(over.id))}.` + : `${name(String(active.id))} is over no column and will stay where it is.`, + onDragEnd: ({ active, over }) => + over + ? `${name(String(active.id))} was dropped on ${columnName(String(over.id))}.` + : `${name(String(active.id))} stayed where it is.`, + onDragCancel: ({ active }) => + `Moving ${name(String(active.id))} was cancelled; it stayed where it is.`, + }; + + // dnd-kit's default instructions promise the space bar and the arrow keys, + // which belong to a keyboard sensor this board deliberately does not have + // (see the file comment). Left as they are, they describe to the one reader + // who depends on them an affordance that is not there, instead of the Move + // control that is. + const instructions: ScreenReaderInstructions = { + draggable: + "A card can be dragged onto another column with a pointer. Every card that may move also carries a Move control, which lists the columns it may go to.", + }; + + const sensors = useSensors( + // 5px before a drag begins, so a click on a control inside a card still + // lands; 250ms on touch, so a swipe still scrolls the page. + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 250, tolerance: 5 }, + }), + ); + + const draggedItem = found(dragged); + + return ( +
    + {searchText && ( + setQuery(event.target.value)} + style={{ maxWidth: "28rem" }} + /> + )} + + setDragged(String(active.id).slice(CARD.length)) + } + onDragCancel={() => setDragged(null)} + onDragEnd={({ active, over }) => { + setDragged(null); + const item = found(String(active.id).slice(CARD.length)); + const column = columns.find( + (candidate) => `${COLUMN}${candidate.id}` === String(over?.id), + ); + if (item && column && movable(item, column)) onMove?.(item, column); + }} + > +
    + {columns.map((column) => { + const cards = byColumn.get(column.id) ?? []; + return ( + +
      + {cards.map((item) => { + const id = idOf(item); + const label = labelOf(item); + const targets = columns.filter((target) => + movable(item, target), + ); + return ( + 0} + > + {onOpen ? ( + + ) : ( +
      {renderCard(item)}
      + )} + {targets.length > 0 && ( +
      + + + {`Move ${label}`} + + + {targets.map((target) => ( + onMove?.(item, target)} + > + {target.label} + + ))} + + +
      + )} +
      + ); + })} + {cards.length === 0 && ( +
    • + {column.empty ?? "Nothing here"} +
    • + )} +
    +
    + ); + })} +
    + {/* The floating copy is the card's contents without its controls: a + second button with the same accessible name, mid-drag, would be a + second target a reader could reach. */} + + {draggedItem ? ( +
    + {renderCard(draggedItem)} +
    + ) : null} +
    +
    +
    + ); +} diff --git a/module/services/frontend/code/packages/codefly-ui/src/board/cn.ts b/module/services/frontend/code/packages/codefly-ui/src/board/cn.ts new file mode 100644 index 000000000..bf05e05e7 --- /dev/null +++ b/module/services/frontend/code/packages/codefly-ui/src/board/cn.ts @@ -0,0 +1,65 @@ +// The board tier's class joiner. Uses clsx + tailwind-merge so a caller's +// `className` (passed last) wins conflicting Tailwind utilities against a +// component's own classes — the same override semantics as the layout tier's +// `cn`. Kept as a self-contained per-subpath file so `@codefly-dev/ui/board` +// bundles independently, but behaviourally identical to `layout/cn`, +// `dashboard/cn` and `chat/cn`. +import { type ClassValue, clsx } from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +// The skin's type slots (`type-card-title`) and control rungs (`control-sm`) are +// custom utilities, so stock tailwind-merge does not know them. What it must +// know is narrow, and deliberately so. +// +// A slot or rung is COMPOSITE: one class sets several properties. tailwind-merge +// can only keep or drop a whole class, so declaring `text-lg` as conflicting with +// `type-card-title` would make a caller's size override delete the slot's +// weight, line height and family with it — and `h-11` would strip a button's +// padding and text along with its height. Instead the generated utilities sit at +// zero specificity (`:where(&)`, see scripts/generate-type-utilities.mjs in +// this package), so a core utility on the same element overrides exactly the property it +// names, whatever the source order. `cn` keeps both classes and the cascade does +// the partial override no merge could. +// +// What `cn` does resolve is skin-against-skin, where two whole shapes really do +// replace each other and the later one must win: +// +// type- the slot's text: two slots on one element is a bug +// control- height, inline padding and the rung's text +// control-height- height alone (a container sized to a rung) +// control-icon- a square control: height, width, no inline padding +// control-glyph- the glyph inside a control: width and height +// +// A slot beside a rung is left alone on purpose: the slot's text is emitted +// after the rung's and wins by source order, which is the intended reading. +const SIZE_NAME = (value: string) => + ["xs", "sm", "default", "lg"].includes(value); + +const twMerge = extendTailwindMerge< + | "skin-type" + | "skin-control" + | "skin-control-height" + | "skin-control-icon" + | "skin-control-glyph" +>({ + extend: { + classGroups: { + "skin-type": [{ type: [(value: string) => value.length > 0] }], + "skin-control": [{ control: [SIZE_NAME] }], + "skin-control-height": [{ control: [{ height: [SIZE_NAME] }] }], + "skin-control-icon": [{ control: [{ icon: [SIZE_NAME] }] }], + "skin-control-glyph": [{ control: [{ glyph: [SIZE_NAME] }] }], + }, + conflictingClassGroups: { + // A later full rung replaces an earlier one of any shape that also + // sets the height. A later height-only rung does NOT replace a full + // rung: it is emitted after it and overrides the height alone. + "skin-control": ["skin-control-height", "skin-control-icon"], + "skin-control-icon": ["skin-control", "skin-control-height"], + }, + }, +}); + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/module/services/frontend/code/packages/codefly-ui/src/board/index.ts b/module/services/frontend/code/packages/codefly-ui/src/board/index.ts new file mode 100644 index 000000000..551315878 --- /dev/null +++ b/module/services/frontend/code/packages/codefly-ui/src/board/index.ts @@ -0,0 +1,9 @@ +// The board tier: one collection in columns, one column per value of a field, +// whose cards a reader drags — or moves from a menu — between them. Exported +// from `@codefly-dev/ui/board` (a client subpath), shared as a +// Module-Federation singleton like every other kit subpath. +// +// The board commits nothing: a move is reported to the consumer, which confirms +// it, writes it, or refuses it. See board.tsx. + +export { Board, type BoardColumn, type BoardProps } from "./board.js"; diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/__tests__/source-offsets.test.tsx b/module/services/frontend/code/packages/codefly-ui/src/content/__tests__/source-offsets.test.tsx new file mode 100644 index 000000000..5f030acd8 --- /dev/null +++ b/module/services/frontend/code/packages/codefly-ui/src/content/__tests__/source-offsets.test.tsx @@ -0,0 +1,679 @@ +// @vitest-environment happy-dom +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { Markdown } from "../markdown.js"; +import { byteOffsets } from "../source-offsets.js"; + +afterEach(cleanup); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const bytes = (text: string) => encoder.encode(text); +const at = (source: string, start: number, end: number) => + decoder.decode(bytes(source).subarray(start, end)); + +// Front matter before the body and accents inside it, so a character offset and +// a byte offset disagree everywhere: this fixture fails against a renderer that +// emits string indices, and an ASCII one would not. The same shape as the +// stopgap a consuming solution wrote for itself and this replaces. +const VERSION = + "---\ntitle: Ça\n---\n\n# Té\n\nUn paragraphe **gras** et [lien](../x.md).\n\n- Première\n- Deuxième\n\n| Clé | Valeur |\n| --- | --- |\n| à | é |\n\n> Une citation éclairée\n\n```ts\nconst é = 1;\n```\n"; +const BODY_AT = VERSION.indexOf("# Té"); +const BODY = VERSION.slice(BODY_AT); +const BODY_BASE = bytes(VERSION.slice(0, BODY_AT)).length; + +function renderVersion() { + return render( + + {BODY} + , + ); +} + +/** + * What the annotations kit's reading layer does with the attributes + * (`annotations-ui/src/anchors/source-map.ts`, `textRuns`): each text node is + * owned by its nearest marked ancestor; inside an exact owner the runs are + * consecutive, and inside any other the whole owner's range stands for all of + * them. Reimplemented here rather than imported, because the host depends on no + * module — if these runs read back the right bytes, that reader maps a selection + * correctly. + */ +interface Run { + text: string; + start: number; + end: number; + exact: boolean; +} + +function runs(root: Element, content: Uint8Array): Run[] { + const out: Run[] = []; + const consumed = new Map(); + const walker = root.ownerDocument.createTreeWalker(root, 4 /* TEXT */); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node as Text; + if (text.parentElement?.closest("[data-source-ignore]")) continue; + let owner: Element | null = text.parentElement; + while ( + owner && + owner !== root.parentElement && + !owner.hasAttribute("data-source-start") + ) + owner = owner.parentElement; + if (!owner || owner === root.parentElement) continue; + const start = Number(owner.getAttribute("data-source-start")); + const end = Number(owner.getAttribute("data-source-end")); + const exact = + owner.getAttribute("data-source-exact") !== "false" && + decoder.decode(content.subarray(start, end)) === + (owner.textContent ?? ""); + const length = bytes(text.data).length; + const before = consumed.get(owner) ?? 0; + consumed.set(owner, before + length); + out.push( + exact + ? { + text: text.data, + start: start + before, + end: start + before + length, + exact, + } + : { text: text.data, start, end, exact }, + ); + } + return out; +} + +const marked = (container: Element) => + Array.from(container.querySelectorAll("[data-source-start]")); + +describe("byteOffsets maps a string index to the UTF-8 byte it starts at", () => { + it.each([ + "plain ascii", + "accents: Ça é à", + "an emoji 🎉 after it", + "mixed 中文 and 🇫🇷 flags", + ])("agrees with TextEncoder on every index of %s", (source) => { + const map = byteOffsets(source); + for (let index = 0; index <= source.length; index++) { + // An index between the halves of a surrogate pair names no byte; it reads + // as the character's end, which TextEncoder cannot be asked for. + const code = source.charCodeAt(index); + if (code >= 0xdc00 && code <= 0xdfff) continue; + expect(map(index), `index ${index}`).toBe( + bytes(source.slice(0, index)).length, + ); + } + }); + + it("counts a lone surrogate as the replacement character the encoder writes", () => { + const lone = `a\ud800b`; + expect(byteOffsets(lone)(3)).toBe(bytes(lone).length); + }); + + it("clamps an index outside the source rather than reading past the array", () => { + const map = byteOffsets("Ça"); + expect(map(-5)).toBe(0); + expect(map(99)).toBe(3); + }); +}); + +describe("Markdown emits the bytes it rendered, in the version's byte space", () => { + it("marks every block an annotation can be written on", () => { + const { container } = renderVersion(); + for (const selector of ["h1", "p", "li", "th", "td", "blockquote", "pre"]) { + const elements = Array.from(container.querySelectorAll(selector)); + expect(elements.length, selector).toBeGreaterThan(0); + for (const element of elements) + expect( + element.hasAttribute("data-source-start"), + `${selector} carries no source range`, + ).toBe(true); + } + }); + + it("gives every range a valid, character-aligned slice of the version", () => { + const { container } = renderVersion(); + const content = bytes(VERSION); + const elements = marked(container); + expect(elements.length).toBeGreaterThan(10); + for (const element of elements) { + const start = Number(element.getAttribute("data-source-start")); + const end = Number(element.getAttribute("data-source-end")); + expect(Number.isInteger(start) && Number.isInteger(end)).toBe(true); + expect(start).toBeGreaterThanOrEqual(BODY_BASE); + expect(end).toBeLessThanOrEqual(content.length); + expect(end).toBeGreaterThan(start); + // A continuation byte at either end would cut a character in half, and + // the annotations owner refuses such a locator outright. + expect( + (content[start] as number) & 0xc0, + "start cuts a character", + ).not.toBe(0x80); + if (end < content.length) + expect( + (content[end] as number) & 0xc0, + "end cuts a character", + ).not.toBe(0x80); + } + }); + + it("says a run is exact only when the text IS those bytes", () => { + const { container } = renderVersion(); + for (const element of marked(container)) { + if (element.getAttribute("data-source-exact") === "false") continue; + const start = Number(element.getAttribute("data-source-start")); + const end = Number(element.getAttribute("data-source-end")); + expect(at(VERSION, start, end)).toBe(element.textContent); + } + }); + + it("reads every rendered run back to the text it shows", () => { + const { container } = renderVersion(); + const root = container.querySelector("[data-slot=content-markdown]"); + const found = runs(root as Element, bytes(VERSION)); + expect(found.length).toBeGreaterThan(8); + for (const run of found) { + if (run.exact) expect(at(VERSION, run.start, run.end)).toBe(run.text); + // An inexact run maps as a whole: its text is somewhere in those bytes. + else + expect(at(VERSION, run.start, run.end)).toContain( + run.text.trim().slice(0, 4), + ); + } + }); + + it("keeps a plain run byte-exact and marks what markdown rewrote", () => { + const { container } = renderVersion(); + const exact = Array.from( + container.querySelectorAll( + "span[data-source-start]:not([data-source-exact])", + ), + ).find((span) => span.textContent === "Un paragraphe "); + expect( + exact, + "the plain run before the emphasis is not an exact span", + ).toBeDefined(); + expect( + at( + VERSION, + Number(exact?.getAttribute("data-source-start")), + Number(exact?.getAttribute("data-source-end")), + ), + ).toBe("Un paragraphe "); + + // The heading dropped its `#`, the emphasis its `**`, the item its bullet, + // the link its target: each is a block whose text is not its bytes. + for (const selector of [ + "h1", + "strong", + "li", + "[data-slot=content-link-inert]", + "pre", + ]) { + const element = container.querySelector(selector); + expect(element?.getAttribute("data-source-exact"), selector).toBe( + "false", + ); + } + }); + + it("maps a fenced block as a whole and its code character for character", () => { + const { container } = renderVersion(); + const pre = container.querySelector("pre") as HTMLElement; + const code = pre.querySelector("code") as HTMLElement; + expect(pre.getAttribute("data-source-exact")).toBe("false"); + expect( + at( + VERSION, + Number(pre.getAttribute("data-source-start")), + Number(pre.getAttribute("data-source-end")), + ), + ).toContain("```ts"); + expect(code.getAttribute("data-source-exact")).toBe(null); + expect( + at( + VERSION, + Number(code.getAttribute("data-source-start")), + Number(code.getAttribute("data-source-end")), + ), + ).toBe(code.textContent); + }); + + it("falls back to the whole block when the body is not in the source verbatim", () => { + // An indented code block, and a fence inside a list item whose body runs to + // a second line: the parser strips the indent from each line, so those + // bytes are nowhere in the source as one run. The block still carries a + // range — a reader can comment on it — and says it is not exact rather than + // claiming bytes it cannot stand behind. + const source = + "- item:\n\n ```ts\n const a = 1;\n const b = 2;\n ```\n\nand then\n\n four spaces\n and more\n"; + const { container } = render({source}); + const blocks = Array.from(container.querySelectorAll("pre")); + expect(blocks).toHaveLength(2); + for (const pre of blocks) { + const code = pre.querySelector("code") as HTMLElement; + expect(code.getAttribute("data-source-exact")).toBe("false"); + const start = Number(code.getAttribute("data-source-start")); + const end = Number(code.getAttribute("data-source-end")); + expect(end).toBeGreaterThan(start); + // Still a range a reader can write a comment on: it holds the text, even + // though it is not only the text. + expect(at(source, start, end)).toContain( + (code.textContent ?? "").split("\n")[0]?.trim(), + ); + } + }); + + // A text comparison cannot catch this one: the body's text can occur inside + // the opening line, so a range pointing into the info string reads back as the + // right text while naming the wrong bytes. Only the offset tells them apart. + it.each([ + { source: "```python\nn\n```\n", start: 10, end: 11 }, + { source: "~~~\n~\n~~~\n", start: 4, end: 5 }, + { source: "```ts\nconst a = 1;\n```\n", start: 6, end: 18 }, + ])( + "puts a fence's body after its opening line, not inside it ($source)", + ({ source, start, end }) => { + const { container } = render({source}); + const code = container.querySelector("code") as HTMLElement; + expect(Number(code.getAttribute("data-source-start"))).toBe(start); + expect(Number(code.getAttribute("data-source-end"))).toBe(end); + expect(code.getAttribute("data-source-exact")).toBe(null); + expect(at(source, start, end)).toBe(code.textContent); + // Belt and braces: whatever the text says, the body cannot begin before + // the newline that closed the opening fence. + expect(start).toBeGreaterThan(source.indexOf("\n")); + }, + ); + + it("counts bytes rather than characters, so the accents do not shift it", () => { + const { container } = renderVersion(); + // `# Té` is four characters and five bytes. A renderer emitting string + // indices would end this heading one byte early, and every block after the + // front matter's `Ça` two bytes early — which is why the fixture has both. + const heading = container.querySelector("h1") as HTMLElement; + expect(Number(heading.getAttribute("data-source-start"))).toBe(BODY_BASE); + expect(Number(heading.getAttribute("data-source-end"))).toBe(BODY_BASE + 5); + // The front matter's own accent already makes the byte offset differ from + // the string index the parser counts in. + expect(BODY_BASE).toBe(BODY_AT + 1); + + // Two accents in one byte-exact paragraph: 21 characters, 23 bytes. + const quoted = container.querySelector("blockquote p") as HTMLElement; + const start = Number(quoted.getAttribute("data-source-start")); + const end = Number(quoted.getAttribute("data-source-end")); + expect(quoted.textContent).toBe("Une citation éclairée"); + expect(end - start).toBe(23); + expect(at(VERSION, start, end)).toBe(quoted.textContent); + }); + + it("offsets name the version, not the slice, under sourceStart", () => { + const { container } = render( + + {BODY} + , + ); + const first = Number( + container.querySelector("h1")?.getAttribute("data-source-start"), + ); + expect(first).toBe(0); + const { container: shifted } = renderVersion(); + expect( + Number(shifted.querySelector("h1")?.getAttribute("data-source-start")), + ).toBe(BODY_BASE); + }); + + it("emits nothing when the caller did not ask", () => { + const { container } = render({BODY}); + expect(container.querySelectorAll("[data-source-start]")).toHaveLength(0); + }); + + it("keeps a run's bytes when a single newline is a line break", () => { + const source = "Première ligne\nseconde ligne\n"; + const { container } = render( + + {source} + , + ); + expect(container.querySelectorAll("br")).toHaveLength(1); + const spans = Array.from( + container.querySelectorAll("span[data-source-start]"), + ); + expect(spans).toHaveLength(2); + for (const span of spans) + expect( + at( + source, + Number(span.getAttribute("data-source-start")), + Number(span.getAttribute("data-source-end")), + ), + ).toBe(span.textContent); + }); + + // A text node's value is NOT its source: the parser drops a line's + // indentation and a soft break's trailing space, and decodes `&` to one + // character. Counting each piece along the value therefore puts every piece + // after the first difference on bytes that are not its text — and marking it + // inexact does not save it, because an inexact run's own range is what a + // comment is written against. Each piece is located in the source instead. + describe.each([ + { + name: "a list item's continuation line, whose indent the parser dropped", + source: "- first line\n second line\n", + exact: ["first line", "second line"], + fallback: undefined, + }, + { + name: "a soft break whose trailing space the parser dropped", + source: "foo \nbar\n", + exact: ["foo", "bar"], + fallback: undefined, + }, + { + // `&` is five bytes rendered as one character, so the first piece is + // nowhere in the source as written. Nothing after it can be located + // either — a search from a cursor that never advanced past it could match + // BEFORE the piece's real place — so every piece falls back to the whole + // text node, which the reader widens to anyway. + name: "a decoded entity, which no run can be located past", + source: "a & b\nc d\n", + exact: [], + fallback: [0, 13] as [number, number], + }, + ])("a line break in $name", ({ source, exact, fallback }) => { + it("gives every run bytes that are its text, or one honest coarse range", () => { + const { container } = render( + + {source} + , + ); + const spans = Array.from( + container.querySelectorAll("span[data-source-start]"), + ); + expect(spans.length).toBeGreaterThan(1); + const exactly: string[] = []; + const coarse = new Set(); + for (const span of spans) { + const from = Number(span.getAttribute("data-source-start")); + const to = Number(span.getAttribute("data-source-end")); + if (span.getAttribute("data-source-exact") === "false") { + coarse.add(`${from},${to}`); + continue; + } + expect(at(source, from, to)).toBe(span.textContent); + exactly.push(span.textContent ?? ""); + } + expect(exactly).toEqual(exact); + // The bug this replaced gave each piece its own WRONG range. The fallback + // gives them all the same one, so a comment is coarse rather than + // misplaced: one range, and it is the whole node. + expect([...coarse]).toEqual( + fallback ? [`${fallback[0]},${fallback[1]}`] : [], + ); + }); + }); + + // A text comparison cannot catch this one: the body's text can occur inside + // the opening line, so a range pointing into the info string reads back as the + // right text while naming the wrong bytes. Only the offset tells them apart. + it.each([ + { source: "```python\nn\n```\n", start: 10, end: 11 }, + { source: "~~~\n~\n~~~\n", start: 4, end: 5 }, + { source: "```ts\nconst a = 1;\n```\n", start: 6, end: 18 }, + ])( + "puts a fence's body after its opening line, not inside it ($source)", + ({ source, start, end }) => { + const { container } = render({source}); + const code = container.querySelector("code") as HTMLElement; + expect(Number(code.getAttribute("data-source-start"))).toBe(start); + expect(Number(code.getAttribute("data-source-end"))).toBe(end); + expect(code.getAttribute("data-source-exact")).toBe(null); + expect(at(source, start, end)).toBe(code.textContent); + // Belt and braces: whatever the text says, the body cannot begin before + // the newline that closed the opening fence. + expect(start).toBeGreaterThan(source.indexOf("\n")); + }, + ); + + it("counts bytes rather than characters, so the accents do not shift it", () => { + const { container } = renderVersion(); + // `# Té` is four characters and five bytes. A renderer emitting string + // indices would end this heading one byte early, and every block after the + // front matter's `Ça` two bytes early — which is why the fixture has both. + const heading = container.querySelector("h1") as HTMLElement; + expect(Number(heading.getAttribute("data-source-start"))).toBe(BODY_BASE); + expect(Number(heading.getAttribute("data-source-end"))).toBe(BODY_BASE + 5); + // The front matter's own accent already makes the byte offset differ from + // the string index the parser counts in. + expect(BODY_BASE).toBe(BODY_AT + 1); + + // Two accents in one byte-exact paragraph: 21 characters, 23 bytes. + const quoted = container.querySelector("blockquote p") as HTMLElement; + const start = Number(quoted.getAttribute("data-source-start")); + const end = Number(quoted.getAttribute("data-source-end")); + expect(quoted.textContent).toBe("Une citation éclairée"); + expect(end - start).toBe(23); + expect(at(VERSION, start, end)).toBe(quoted.textContent); + }); + + it("offsets name the version, not the slice, under sourceStart", () => { + const { container } = render( + + {BODY} + , + ); + const first = Number( + container.querySelector("h1")?.getAttribute("data-source-start"), + ); + expect(first).toBe(0); + const { container: shifted } = renderVersion(); + expect( + Number(shifted.querySelector("h1")?.getAttribute("data-source-start")), + ).toBe(BODY_BASE); + }); + + it("emits nothing when the caller did not ask", () => { + const { container } = render({BODY}); + expect(container.querySelectorAll("[data-source-start]")).toHaveLength(0); + }); + + it("keeps a run's bytes when a single newline is a line break", () => { + const source = "Première ligne\nseconde ligne\n"; + const { container } = render( + + {source} + , + ); + expect(container.querySelectorAll("br")).toHaveLength(1); + const spans = Array.from( + container.querySelectorAll("span[data-source-start]"), + ); + expect(spans).toHaveLength(2); + for (const span of spans) + expect( + at( + source, + Number(span.getAttribute("data-source-start")), + Number(span.getAttribute("data-source-end")), + ), + ).toBe(span.textContent); + }); + + it("does not rewrite the source for references, which would move every offset", () => { + const source = "Voir [1] et [2] après.\n"; + const { container } = render( + `<${n}>` }} + > + {source} + , + ); + expect(container.textContent).toContain("[1]"); + expect(container.textContent).not.toContain("<1>"); + expect(container.querySelectorAll("[data-source-exact]")).toHaveLength(0); + for (const span of container.querySelectorAll("span[data-source-start]")) { + if (span.getAttribute("data-source-exact") === "false") continue; + expect( + at( + source, + Number(span.getAttribute("data-source-start")), + Number(span.getAttribute("data-source-end")), + ), + ).toBe(span.textContent); + } + }); +}); + +describe("a resolved link is the caller's answer, held to the kit's allowlist", () => { + it("opens an external target in a new context and an internal one in place", () => { + render( + + href.startsWith("../") + ? { + href: `https://example.com/repo/${href.slice(3)}`, + external: true, + } + : { href: `https://product.example/pages/${href}`, external: false } + } + > + {"[away](../decisions/x.md#why) and [here](guide.md)\n"} + , + ); + const away = screen.getByRole("link", { name: "away" }); + expect(away.getAttribute("href")).toBe( + "https://example.com/repo/decisions/x.md#why", + ); + expect(away.getAttribute("target")).toBe("_blank"); + expect(away.getAttribute("rel")).toBe("noopener noreferrer"); + const here = screen.getByRole("link", { name: "here" }); + expect(here.getAttribute("href")).toBe( + "https://product.example/pages/guide.md", + ); + expect(here.getAttribute("target")).toBe(null); + expect(here.getAttribute("rel")).toBe(null); + }); + + it.each([ + "javascript:alert(1)", + "data:text/html,", + "vbscript:msgbox(1)", + "https://bank.example@evil.example/", + "relative/not/a/url", + ])("refuses %s however the resolver spells it", (resolved) => { + const { container } = render( + ({ href: resolved, external: true })}> + {"[text](../x.md)\n"} + , + ); + expect(container.querySelectorAll("a")).toHaveLength(0); + expect( + container.querySelector("[data-slot=content-link-inert]")?.textContent, + ).toBe("text"); + }); + + it("leaves the kit's own rule in place when the resolver declines", () => { + render( + undefined}> + {"[up](https://example.com/a)\n"} + , + ); + expect(screen.getByRole("link", { name: "up" }).getAttribute("rel")).toBe( + "noopener noreferrer", + ); + }); + + it("carries the source range on a resolved link, so it can be annotated", () => { + const source = "Voir [lien](../x.md).\n"; + const { container } = render( + ({ href: "https://example.com/x", external: true })} + > + {source} + , + ); + const link = container.querySelector("a") as HTMLElement; + expect( + at( + source, + Number(link.getAttribute("data-source-start")), + Number(link.getAttribute("data-source-end")), + ), + ).toBe("[lien](../x.md)"); + expect(link.getAttribute("data-source-exact")).toBe("false"); + }); +}); + +describe("source offsets change nothing about the untrusted-content rules", () => { + it("still drops raw HTML and still refuses a javascript: link in the source", () => { + const { container } = render( + + { + '\n\n[x](javascript:alert(1))\n\nbold\n' + } + , + ); + expect(container.querySelectorAll("img")).toHaveLength(0); + expect(container.querySelectorAll("b")).toHaveLength(0); + expect(container.querySelectorAll("a")).toHaveLength(0); + expect(container.innerHTML).not.toContain("onerror"); + }); +}); + +describe("chrome the renderer added belongs to no source", () => { + it("leaves a code block's copy control outside every range the reader reads", async () => { + // Inside a list item the control DOES have a marked ancestor, so without + // `data-source-ignore` its announcement would be read as text of the source + // and lend the item's bytes to a word the document never wrote. + const source = "- the code:\n\n ```ts\n const a = 1;\n ```\n"; + const { container } = render({source}); + const control = container.querySelector( + "[data-slot=content-code] [data-source-ignore]", + ) as HTMLElement; + expect(control).not.toBeNull(); + expect(control.closest("li")).not.toBeNull(); + + // Make it say something, so there is text to exclude rather than an empty + // node a passing test could not tell from an excluded one. + await act(async () => { + fireEvent.click( + control.querySelector("[data-slot=content-copy]") as HTMLElement, + ); + }); + expect(control.textContent).toMatch(/Cop/); + + const root = container.querySelector( + "[data-slot=content-markdown]", + ) as Element; + for (const run of runs(root, bytes(source))) + expect(run.text).not.toMatch(/Cop/); + }); + + it("leaves an unavailable link's reason outside the link's range", () => { + const source = "See [the guide](../guide.md).\n"; + const { container } = render( + ({ unavailable: "not here" })}> + {source} + , + ); + const link = container.querySelector( + "[data-slot=content-link-unavailable]", + ) as HTMLElement; + expect(link.getAttribute("data-source-start")).not.toBeNull(); + expect(link.textContent).toContain("not here"); + const root = container.querySelector( + "[data-slot=content-markdown]", + ) as Element; + for (const run of runs(root, bytes(source))) + expect(run.text).not.toContain("not here"); + }); +}); diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/code-block.tsx b/module/services/frontend/code/packages/codefly-ui/src/content/code-block.tsx index 337351196..685043b4f 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/content/code-block.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/content/code-block.tsx @@ -3,6 +3,11 @@ import { type ReactNode, useEffect, useState } from "react"; import { cn } from "../layout/cn.js"; import { CopyButton } from "./copy-button.js"; +import { + SOURCE_IGNORE, + type SourceRange, + sourceAttributes, +} from "./source-offsets.js"; export interface CodeBlockProps { code: string; @@ -14,6 +19,15 @@ export interface CodeBlockProps { copyable?: boolean; /** Wrap long lines instead of scrolling horizontally. Default false. */ wrap?: boolean; + /** + * The bytes of the source this block was rendered from, for a reader that + * annotates it — the whole block, fence delimiters and language included, so + * it is never byte-exact. Set by `Markdown` under `sourceOffsets`; a direct + * caller leaves both ranges unset and the block carries no offsets. + */ + source?: SourceRange; + /** The bytes of the code itself, which the `` renders verbatim. */ + bodySource?: SourceRange; className?: string; } @@ -34,6 +48,8 @@ export function CodeBlock({ highlight = true, copyable = true, wrap = false, + source, + bodySource, className, }: CodeBlockProps) { const lang = language?.trim().toLowerCase() || undefined; @@ -69,7 +85,13 @@ export function CodeBlock({ )} > {copyable && ( -
    + // Chrome, not content: a block inside a marked region (a fence in a + // list item) would otherwise lend the copy control's announcement to + // the source it sits in. The attribute is inert without one. +
    )} @@ -79,8 +101,11 @@ export function CodeBlock({ wrap ? "whitespace-pre-wrap break-words" : "whitespace-pre", copyable && "pr-10", )} + {...sourceAttributes(source)} > - {body} + + {body} +
    ); diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/index.ts b/module/services/frontend/code/packages/codefly-ui/src/content/index.ts index bed5b3c8b..b8dfff5d9 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/content/index.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/content/index.ts @@ -5,7 +5,8 @@ // content from one instance, with one copy of the markdown parser. // // Security posture (see markdown.tsx and url.ts): no raw HTML, links only for -// http/https/mailto, images off unless opted in, no innerHTML anywhere. +// http/https/mailto (a resolved href included), images off unless opted in, no +// innerHTML anywhere. export { CodeBlock, type CodeBlockProps } from "./code-block.js"; export { Content, type ContentProps, type ContentVariant } from "./content.js"; @@ -39,5 +40,19 @@ export { type PlainTextOptions, toPlainText, } from "./plain.js"; +export { + byteOffsets, + readSourceRange, + rehypeSourceOffsets, + SOURCE_END, + SOURCE_EXACT, + SOURCE_IGNORE, + SOURCE_START, + type SourceAttributes, + type SourceOffsetsOptions, + type SourceRange, + sourceAttributes, + sourceProps, +} from "./source-offsets.js"; export { TextBlock, type TextBlockProps } from "./text-block.js"; export { safeImageUrl, safeLinkUrl } from "./url.js"; diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/links.ts b/module/services/frontend/code/packages/codefly-ui/src/content/links.ts index 4d8bcf1c6..cad759dd9 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/content/links.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/content/links.ts @@ -12,12 +12,21 @@ * - `{ unavailable }`: the link's text, not live, with the reason as its title. * For a target the caller cannot show: not in the viewer's readable set, or * not there at all. The reason must not say which. + * - `{ href, external }`: where the link actually goes, which only the caller + * knows — a corpus page's `../decisions/x.md#why` is a path in a repository, + * not a URL. `external` opens it in a new browsing context with + * `rel="noopener noreferrer"`; otherwise it opens in place. The href is held + * to the SAME allowlist as one the content wrote (`safeLinkUrl`, resolved + * against `linkBase` when there is one), so a resolver cannot make + * `javascript:` into a link: give an absolute http, https or mailto URL, or + * return `{ open }` to navigate inside the product without one. * - `undefined`: the kit's own rule applies (an absolute http, https or mailto * URL is live in a new tab; `linkBase` resolves a relative one first). */ export type LinkTarget = | { open: () => void; title?: string } | { unavailable: string } + | { href: string; external: boolean } | undefined; /** Decides, from a link's href exactly as written, what the link does. */ diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/markdown.tsx b/module/services/frontend/code/packages/codefly-ui/src/content/markdown.tsx index 551fe1791..292324c2a 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/content/markdown.tsx +++ b/module/services/frontend/code/packages/codefly-ui/src/content/markdown.tsx @@ -14,7 +14,9 @@ // 3. Images are OFF unless the caller opts in, because rendering one fetches // it: an untrusted document could otherwise make every reader's browser // call a URL of its choosing. Off, an image renders as its alt text. -// 4. The only id that reaches the page is one the kit minted, under this +// 4. A link the caller RESOLVES is held to the same allowlist as one the +// content wrote, so `resolveLink` cannot turn `javascript:` into a link. +// 5. The only id that reaches the page is one the kit minted, under this // block's own `clobberPrefix` namespace (`useOwnId`). That is what makes a // GFM footnote's anchor work without letting a document write an id that // collides with, or shadows, the host's own — or another block's. @@ -32,6 +34,7 @@ import ReactMarkdown, { type Components, type ExtraProps, } from "react-markdown"; +import type { PluggableList } from "unified"; import { cn } from "../layout/cn.js"; import { CodeBlock } from "./code-block.js"; import { remarkGfmParse } from "./gfm.js"; @@ -42,6 +45,13 @@ import { remarkLineBreaks, remarkReferences, } from "./references.js"; +import { + readSourceRange, + rehypeSourceOffsets, + SOURCE_IGNORE, + type SourceAttributes, + sourceProps, +} from "./source-offsets.js"; import { safeImageUrl, safeLinkUrl } from "./url.js"; /** A heading level, for mapping markdown's `#` onto the page's outline. */ @@ -88,8 +98,32 @@ export interface MarkdownProps { * citation marker. Only the listed numbers are references; any other `[n]` * stays markdown. See references.ts for how a model's `[n](url)` and * `[n]: url` forms are handled. + * + * Ignored under `sourceOffsets`: protecting a marker rewrites the source + * before it is parsed, which would move every offset after it. Citations are + * an answer's affordance, a byte range a stored document's. */ references?: MarkdownReferences; + /** + * Mark what was rendered with the bytes it came from: `data-source-start` and + * `data-source-end` on every block and every text run, and + * `data-source-exact="false"` where markdown rewrote the text (a heading's + * `#`, emphasis, a link, a list bullet, a code fence). Plain runs are + * byte-exact, so an annotation layer maps a selection character for + * character inside them and to the whole element elsewhere. + * + * The offsets are UTF-8, half-open, and counted in the version's bytes — the + * contract of the annotations kit's text-range locator, not the kit's own + * invention. Default off: it adds a `` around every text run. + */ + sourceOffsets?: boolean; + /** + * The byte offset at which `children` starts inside the version it is part + * of. A document rendered without its front matter passes the front matter's + * byte length, so the offsets still name the version's bytes rather than the + * slice's. Default 0. Only read under `sourceOffsets`. + */ + sourceStart?: number; /** Show a copy button on each fenced code block. Default true. */ copyable?: boolean; className?: string; @@ -131,15 +165,13 @@ function headingFor(depth: number, base: HeadingLevel) { const level = Math.min(6, base + depth - 1); const Tag = `h${level}` as HeadingTag; const slot = HEADING_SLOT[Math.min(depth, HEADING_SLOT.length) - 1]; - return function Heading({ - id, - className, - children, - }: ComponentPropsWithoutRef<"h1"> & ExtraProps) { + return function Heading(props: ComponentPropsWithoutRef<"h1"> & ExtraProps) { + const { id, className, children } = props; return ( {children} @@ -202,8 +234,15 @@ const LINK_CLASS = function SafeLink({ href, id, + source, children, -}: { href: unknown; id?: unknown; children?: ReactNode }) { +}: { + href: unknown; + id?: unknown; + /** The bytes this link was rendered from, when the caller asked for them. */ + source?: SourceAttributes; + children?: ReactNode; +}) { const { base, resolve, anchorPrefix } = useContext(LinkContext); const ownId = useOwnId(id); // An anchor the kit generated for this block — a GFM footnote's marker and @@ -224,6 +263,7 @@ function SafeLink({ id={ownId} data-slot="content-link-anchor" className={LINK_CLASS} + {...source} > {children} @@ -238,6 +278,7 @@ function SafeLink({ id={ownId} data-slot="content-link-internal" title={target.title} + {...source} onClick={() => target.open()} className={cn( LINK_CLASS, @@ -255,62 +296,84 @@ function SafeLink({ data-slot="content-link-unavailable" title={target.unavailable} className="underline decoration-dotted underline-offset-2" + {...source} > {children} - ({target.unavailable}) + {/* The kit's words about the link, not the document's: inside a marked + element it would read as text of the source, so a selection on it + would map to the link's bytes. */} + + {" "} + ({target.unavailable}) + ); } - const safe = safeLinkUrl(href, base); + // A resolved href is the caller's answer to "where does this actually go", + // and it is held to the same allowlist as one the content wrote: the caller + // is trusted to know the destination, not to bypass the scheme rule. + const resolved = + target && "href" in target ? safeLinkUrl(target.href, base) : undefined; + const safe = resolved ?? (target ? undefined : safeLinkUrl(href, base)); if (!safe) return ( - + {children} ); + const external = target && "href" in target ? target.external : true; return ( {children} ); } -function Fence({ node }: ComponentPropsWithoutRef<"pre"> & ExtraProps) { +function Fence(props: ComponentPropsWithoutRef<"pre"> & ExtraProps) { const { copyable } = useContext(CodeContext); - const code = node?.children.find( + const code = props.node?.children.find( (child): child is Element => child.type === "element" && child.tagName === "code", ); if (!code) return null; // The fence's text ends with the newline that closed it. - const text = textOf(code).replace(/\n$/, ""); + const raw = textOf(code); + const text = raw.replace(/\n$/, ""); + // `` carries the body's range and `
    ` the whole fence (see
    +	// markCode in source-offsets.ts). The newline stripped above is one byte of
    +	// that body: dropped from the text but not from the range, the reading layer
    +	// would find the text a byte short of its bytes and map the block as a whole
    +	// rather than character for character.
    +	const body = readSourceRange(code.properties ?? {});
     	return (
     		
     	);
     }
     
     // Named (and capitalised) so `useOwnId` is called from something the rules of
     // hooks recognise as a component; the entry in the map below is the tag.
    -function ListItem({
    -	id,
    -	className,
    -	children,
    -}: ComponentPropsWithoutRef<"li"> & ExtraProps) {
    +function ListItem(props: ComponentPropsWithoutRef<"li"> & ExtraProps) {
    +	const { id, className, children } = props;
     	return (
     		
  • {children}
  • @@ -331,23 +394,27 @@ function buildComponents( h4: headingFor(4, headingLevel), h5: headingFor(5, headingLevel), h6: headingFor(6, headingLevel), - p: ({ children }) => ( -

    {children}

    + p: (props) => ( +

    + {props.children} +

    ), // `id` is forwarded only when the kit minted it: a footnote's marker is // the target of its own back-reference. - a: ({ href, id, children }) => ( - - {children} + a: (props) => ( + + {props.children} ), - img: ({ src, alt }) => { + img: (props) => { + const { src, alt } = props; const safe = allowImages ? safeImageUrl(src) : undefined; if (!safe) { return alt ? ( [{alt}] @@ -361,22 +428,28 @@ function buildComponents( decoding="async" referrerPolicy="no-referrer" className="my-2 h-auto max-w-full rounded-md" + {...sourceProps(props)} /> ); }, - ul: ({ className, children }) => ( + ul: (props) => (
      - {children} + {props.children}
    ), - ol: ({ start, children }) => ( -
      - {children} + ol: (props) => ( +
        + {props.children}
      ), li: ListItem, @@ -391,39 +464,62 @@ function buildComponents( className="mt-1 accent-primary" /> ) : null, - blockquote: ({ children }) => ( -
      - {children} + blockquote: (props) => ( +
      + {props.children}
      ), - hr: () =>
      , - table: ({ children }) => ( + hr: (props) => ( +
      + ), + table: (props) => (
      - {children}
      + + {props.children} +
      ), // GFM column alignment arrives as `style.textAlign`; nothing else does. - th: ({ style, children }) => ( + th: (props) => ( - {children} + {props.children} ), - td: ({ style, children }) => ( + td: (props) => ( - {children} + {props.children} ), // Inline code. A fenced block is taken over whole by `pre` below, so this // only ever sees code inside a line. - code: ({ children }) => ( - - {children} + code: (props) => ( + + {props.children} ), pre: Fence, @@ -469,6 +565,7 @@ const URL_TRANSFORMS = { false: transformUrl(false), } as const; // Plugin lists are stable per combination, like the components. +const NO_REHYPE_PLUGINS: PluggableList = []; const PLUGINS = { plain: [remarkGfmParse], breaks: [remarkGfmParse, remarkLineBreaks], @@ -488,6 +585,8 @@ export function Markdown({ linkBase, resolveLink, references, + sourceOffsets = false, + sourceStart = 0, copyable, className, }: MarkdownProps) { @@ -508,11 +607,24 @@ export function Markdown({ ); const code = useMemo(() => ({ copyable }), [copyable]); const markers = references?.markers; - const cited = useMemo(() => (markers ? new Set(markers) : null), [markers]); + // Offsets name the bytes of the source as given, so under `sourceOffsets` the + // source is never rewritten: protecting a reference marker deletes a `[n]: url` + // definition line, and every offset after it would be wrong by its length. + const cited = useMemo( + () => (markers && !sourceOffsets ? new Set(markers) : null), + [markers, sourceOffsets], + ); const source = useMemo( () => (cited ? protectReferences(children, cited) : children), [children, cited], ); + const rehypePlugins = useMemo( + () => + sourceOffsets + ? [[rehypeSourceOffsets, { source, base: sourceStart }]] + : NO_REHYPE_PLUGINS, + [sourceOffsets, source, sourceStart], + ); const plugins = cited ? lineBreaks ? PLUGINS.both @@ -523,22 +635,23 @@ export function Markdown({ return ( - -
      - +
      - {source} - -
      - + + {source} + +
      +
      ); diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/references.ts b/module/services/frontend/code/packages/codefly-ui/src/content/references.ts index 4c8aa69bc..99cbdf03b 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/content/references.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/content/references.ts @@ -28,8 +28,8 @@ export const REFERENCE_ELEMENT = "content-reference"; const MARKER = /\[(\d+)\]/g; const OPEN = String.fromCharCode(0xe000); const CLOSE = String.fromCharCode(0xe001); -const SENTINEL = new RegExp("\\uE000(\\d+)\\uE001", "g"); -const SENTINEL_CHARS = new RegExp("[\\uE000\\uE001]", "g"); +const SENTINEL = /\uE000(\d+)\uE001/g; +const SENTINEL_CHARS = /[\uE000\uE001]/g; const DEFINITION_LINE = /^[ \t]*\[(\d+)\]:[ \t]+\S.*$/gm; const INLINE_LINK = /\[(\d+)\]\([^\s()]*(?:[ \t]+"[^"]*")?\)/g; @@ -179,12 +179,47 @@ export function remarkReferences() { }; } +/** The source range a node covers, when the parser recorded one. */ +function sourceSpan(node: MdNode): [number, number] | undefined { + const at = node.position as + | { start?: { offset?: number }; end?: { offset?: number } } + | undefined; + const start = at?.start?.offset; + const end = at?.end?.offset; + return typeof start === "number" && typeof end === "number" && start <= end + ? [start, end] + : undefined; +} + +// Only the offsets are read downstream, but a unist point is 1-based and +// `mdast-util-to-hast` drops a position whose line is 0 wholesale — taking the +// offsets with it. So the line and column are the smallest valid pair rather +// than a recount of where the break landed. +const span = (start: number, end: number) => ({ + position: { + start: { line: 1, column: 1, offset: start }, + end: { line: 1, column: 1, offset: end }, + }, +}); + /** * Remark plugin: a single newline inside a paragraph is a line break, the way * chat and model output are written, rather than CommonMark's soft wrap. + * + * Each piece keeps the bytes of the source it came from. A split that dropped + * them would leave the renderer unable to say which bytes a run was rendered + * from (`Markdown`'s `sourceOffsets`), and a run with no position maps to + * nothing — so the whole paragraph would silently lose its anchor the moment a + * caller asked for line breaks. */ export function remarkLineBreaks() { - return (tree: MdNode) => { + return (tree: MdNode, file?: unknown) => { + // The source the parser read. Each piece is LOCATED in it rather than + // counted along the parsed text: a text node's value is not its source — + // the parser drops a line's indentation and a soft break's trailing space, + // and decodes `&` to one character — so counting along the value puts + // every piece after the first difference on bytes that are not its text. + const source = file === undefined || file === null ? "" : String(file); const visit = (node: MdNode) => { if (!node.children) return; const next: MdNode[] = []; @@ -194,11 +229,40 @@ export function remarkLineBreaks() { next.push(child); continue; } - const lines = child.value.split(/\r?\n/); - lines.forEach((line, index) => { - if (index > 0) next.push({ type: "break" }); - if (line !== "") next.push({ type: "text", value: line }); - }); + const at = sourceSpan(child); + // Split keeping the separators, so a `\r\n` is one break like a `\n`. + const pieces = child.value.split(/(\r?\n)/); + let cursor = at?.[0] ?? 0; + // Once a piece cannot be found, no later one can be trusted either: + // the cursor never advanced past it, so a search could match BEFORE + // the piece's real place and claim bytes earlier in the node. From + // there on every piece gets the whole node's range, which the + // renderer then marks inexact — coarse, and true. + let located = at !== undefined && source !== ""; + for (const [index, piece] of pieces.entries()) { + if (index % 2 === 1) { + next.push({ type: "break" }); + continue; + } + if (piece === "") continue; + let found = -1; + if (located) { + found = source.indexOf(piece, cursor); + if (found < 0 || !at || found + piece.length > at[1]) { + located = false; + found = -1; + } else cursor = found + piece.length; + } + next.push({ + type: "text", + value: piece, + ...(found >= 0 + ? span(found, found + piece.length) + : at + ? span(at[0], at[1]) + : {}), + }); + } } node.children = next; }; diff --git a/module/services/frontend/code/packages/codefly-ui/src/content/source-offsets.ts b/module/services/frontend/code/packages/codefly-ui/src/content/source-offsets.ts new file mode 100644 index 000000000..c94ed538f --- /dev/null +++ b/module/services/frontend/code/packages/codefly-ui/src/content/source-offsets.ts @@ -0,0 +1,290 @@ +// Which bytes of its source each piece of rendered markdown came from. +// +// An annotation layer maps a reader's selection back to the bytes of the version +// it was read from, and rendered text is not its source: markdown drops `**`, a +// list gains a bullet nobody wrote, a heading loses its `#`. So the renderer +// says which bytes it rendered rather than letting the reading layer count +// characters off the screen. +// +// The attributes and their meaning are the contract a composed module's +// annotation kit already reads (its `src/anchors/source-map.ts`), matched rather +// than reinvented: +// +// data-source-start / data-source-end UTF-8 offsets `[start, end)` into the +// version's bytes +// data-source-exact="false" the element's text is NOT those bytes +// verbatim, so a selection inside it +// widens to the whole element +// data-source-ignore chrome the renderer added (a copy +// button): not text of the version +// +// Two things are easy to get wrong and are therefore done in one place here. +// +// **Bytes, not characters.** A markdown position is a JS string index; the +// contract counts UTF-8 bytes. `é` is one index and two bytes, an emoji two +// indices and four, so a document of plain ASCII passes a renderer that forgot +// the conversion and a French one does not. Every offset goes through +// `byteOffsets` before it reaches an attribute. +// +// **Exactness is measured, not assumed.** Rather than listing which constructs +// markdown rewrites, each element and each text run is compared with the source +// it claims, so a construct nobody thought of is marked inexact instead of +// lying. The reading layer compares too; this only saves it the work and covers +// the cases it cannot see. + +import type { Element, ElementContent, Root, RootContent, Text } from "hast"; + +/** `data-source-start`: the first byte of the source an element was rendered from. */ +export const SOURCE_START = "data-source-start"; +/** `data-source-end`: one past the last byte (the range is half-open). */ +export const SOURCE_END = "data-source-end"; +/** `data-source-exact`: `"false"` when the text is not those bytes verbatim. */ +export const SOURCE_EXACT = "data-source-exact"; +/** `data-source-ignore`: chrome the renderer added; it maps to no source. */ +export const SOURCE_IGNORE = "data-source-ignore"; + +/** A half-open byte range of the source, and whether the text is it verbatim. */ +export interface SourceRange { + start: number; + end: number; + /** False when markdown rewrote the text; the range then maps as a whole. */ + exact: boolean; +} + +/** The `data-source-*` attributes a range renders as, spreadable onto an element. */ +export interface SourceAttributes { + "data-source-start"?: number; + "data-source-end"?: number; + "data-source-exact"?: string; + "data-source-ignore"?: string; +} + +/** The attributes for one range, or none at all when there is no range. */ +export function sourceAttributes(range?: SourceRange): SourceAttributes { + if (!range) return {}; + return { + [SOURCE_START]: range.start, + [SOURCE_END]: range.end, + ...(range.exact ? {} : { [SOURCE_EXACT]: "false" }), + }; +} + +/** + * The `data-source-*` attributes already on a set of props, forwarded as they + * are. A renderer that intercepts an element (`
      `, ``) must pass them
      + * on: an element that drops them is an element an annotation cannot be written
      + * on, and nothing fails at runtime to say so.
      + */
      +export function sourceProps(props: object): SourceAttributes {
      +	const from = props as Record;
      +	const out: SourceAttributes = {};
      +	const start = from[SOURCE_START];
      +	const end = from[SOURCE_END];
      +	if (typeof start === "number") out[SOURCE_START] = start;
      +	if (typeof end === "number") out[SOURCE_END] = end;
      +	if (from[SOURCE_EXACT] === "false") out[SOURCE_EXACT] = "false";
      +	if (from[SOURCE_IGNORE] !== undefined) out[SOURCE_IGNORE] = "";
      +	return out;
      +}
      +
      +/** The range a set of props carries, for a renderer that passes it to a child. */
      +export function readSourceRange(props: object): SourceRange | undefined {
      +	const attributes = sourceProps(props);
      +	const start = attributes[SOURCE_START];
      +	const end = attributes[SOURCE_END];
      +	if (start === undefined || end === undefined) return undefined;
      +	return { start, end, exact: attributes[SOURCE_EXACT] !== "false" };
      +}
      +
      +/**
      + * Maps a JS string index in `source` to its UTF-8 byte offset, the space the
      + * annotations locator counts in.
      + *
      + * A surrogate pair is one character of four bytes: its bytes are counted at the
      + * high half, and the index between the halves — which names no byte — reads as
      + * the character's end rather than splitting it. A lone surrogate counts as the
      + * three bytes a UTF-8 encoder replaces it with, so the mapping agrees with
      + * `TextEncoder` on text no encoder could round-trip.
      + */
      +export function byteOffsets(source: string): (index: number) => number {
      +	const prefix = new Uint32Array(source.length + 1);
      +	let total = 0;
      +	for (let index = 0; index < source.length; index++) {
      +		prefix[index] = total;
      +		const code = source.charCodeAt(index);
      +		if (code < 0x80) total += 1;
      +		else if (code < 0x800) total += 2;
      +		else if (
      +			code >= 0xd800 &&
      +			code <= 0xdbff &&
      +			(source.charCodeAt(index + 1) & 0xfc00) === 0xdc00
      +		) {
      +			total += 4;
      +			prefix[++index] = total;
      +		} else total += 3;
      +	}
      +	prefix[source.length] = total;
      +	return (index) =>
      +		prefix[Math.min(Math.max(index, 0), source.length)] as number;
      +}
      +
      +/** What the plugin needs to turn markdown positions into the version's bytes. */
      +export interface SourceOffsetsOptions {
      +	/** The markdown exactly as it was handed to the parser. */
      +	source: string;
      +	/**
      +	 * The byte offset at which `source` starts inside the version it is part of,
      +	 * so a body rendered without its front matter still names the version's
      +	 * bytes. Default 0.
      +	 */
      +	base?: number;
      +}
      +
      +interface Marking {
      +	source: string;
      +	at: (index: number) => number;
      +	base: number;
      +}
      +
      +function offsetsOf(node: {
      +	position?: { start?: { offset?: number }; end?: { offset?: number } };
      +}): [number, number] | undefined {
      +	const start = node.position?.start?.offset;
      +	const end = node.position?.end?.offset;
      +	if (typeof start !== "number" || typeof end !== "number") return undefined;
      +	return start <= end ? [start, end] : undefined;
      +}
      +
      +function textOf(node: ElementContent | Root): string {
      +	if (node.type === "text") return node.value;
      +	if (node.type === "element" || node.type === "root") {
      +		let text = "";
      +		for (const child of node.children) text += textOf(child as ElementContent);
      +		return text;
      +	}
      +	return "";
      +}
      +
      +function setRange(
      +	element: Element,
      +	start: number,
      +	end: number,
      +	exact: boolean,
      +	marking: Marking,
      +): void {
      +	element.properties ??= {};
      +	Object.assign(
      +		element.properties,
      +		sourceAttributes({
      +			start: marking.base + marking.at(start),
      +			end: marking.base + marking.at(end),
      +			exact,
      +		}),
      +	);
      +}
      +
      +function mark(element: Element, marking: Marking): void {
      +	const range = offsetsOf(element);
      +	if (!range) return;
      +	setRange(
      +		element,
      +		range[0],
      +		range[1],
      +		textOf(element) === marking.source.slice(range[0], range[1]),
      +		marking,
      +	);
      +}
      +
      +function wrap(text: Text, marking: Marking): Element | undefined {
      +	const range = offsetsOf(text);
      +	if (!range) return undefined;
      +	return {
      +		type: "element",
      +		tagName: "span",
      +		properties: {
      +			...sourceAttributes({
      +				start: marking.base + marking.at(range[0]),
      +				end: marking.base + marking.at(range[1]),
      +				exact: text.value === marking.source.slice(range[0], range[1]),
      +			}),
      +		},
      +		children: [text],
      +	};
      +}
      +
      +/**
      + * A fenced or indented code block. The block's own position covers the fence —
      + * the backticks, the language, the closing line — and its text covers only the
      + * body, which the parser hands over with no position of its own. So the ``
      + * carries the body's range, exact when the body is in the source verbatim (every
      + * fence; an indented block had its indent stripped and is not), and the `
      `
      + * carries the whole block, inexact. A reader therefore comments on the block as
      + * a whole and selects inside it character for character.
      + */
      +function markCode(pre: Element, marking: Marking): void {
      +	const code = pre.children.find(
      +		(child): child is Element =>
      +			child.type === "element" && child.tagName === "code",
      +	);
      +	const fence = offsetsOf(pre);
      +	if (code) {
      +		const span = offsetsOf(code) ?? fence;
      +		const body = textOf(code);
      +		// The search starts after the OPENING LINE of a fence, never at its first
      +		// backtick: the body of ```python\nn\n``` is "n", and a search from the
      +		// backticks finds the `n` of `python` first — a range whose text happens to
      +		// match, so the comparison below cannot catch it and the block would claim
      +		// to be exact while pointing into its own info string. An indented block has
      +		// no opening line to skip, so it is searched from its start.
      +		const from = span ? bodyStart(marking.source, span) : 0;
      +		const at = span ? marking.source.indexOf(body, from) : -1;
      +		if (span && body !== "" && at >= 0 && at + body.length <= span[1]) {
      +			setRange(code, at, at + body.length, true, marking);
      +		} else if (span) setRange(code, span[0], span[1], false, marking);
      +	}
      +	if (fence) setRange(pre, fence[0], fence[1], false, marking);
      +}
      +
      +/** Where a code block's body may start: after the fence's opening line, or at the block. */
      +function bodyStart(source: string, span: [number, number]): number {
      +	const newline = source.indexOf("\n", span[0]);
      +	const fenced = /^(?:`{3,}|~{3,})/.test(
      +		source.slice(span[0], newline < 0 ? span[1] : newline),
      +	);
      +	return fenced && newline >= 0 && newline + 1 <= span[1] ? newline + 1 : span[0];
      +}
      +
      +function annotate(parent: Root | Element, marking: Marking): void {
      +	const out: ElementContent[] = [];
      +	for (const child of parent.children as ElementContent[]) {
      +		if (child.type === "element") {
      +			if (child.tagName === "pre") markCode(child, marking);
      +			else {
      +				annotate(child, marking);
      +				mark(child, marking);
      +			}
      +			out.push(child);
      +			continue;
      +		}
      +		out.push(child.type === "text" ? (wrap(child, marking) ?? child) : child);
      +	}
      +	parent.children = out as RootContent[] & ElementContent[];
      +}
      +
      +/**
      + * A rehype plugin that marks every element and every text run with the bytes it
      + * was rendered from. Text the parser gave no position — a GFM footnote's
      + * generated heading and its `↩` back-reference — is left unmarked and maps to
      + * its nearest marked ancestor as a whole, which is what the contract says an
      + * inexact element does.
      + */
      +export function rehypeSourceOffsets(options: SourceOffsetsOptions) {
      +	const marking: Marking = {
      +		source: options.source,
      +		at: byteOffsets(options.source),
      +		base: options.base ?? 0,
      +	};
      +	return (tree: Root) => {
      +		annotate(tree, marking);
      +	};
      +}
      diff --git a/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css
      index 296a447f5..765c93b9c 100644
      --- a/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css
      +++ b/module/services/frontend/code/packages/codefly-ui/src/skin/preview.generated.css
      @@ -1676,6 +1676,10 @@
           }
         }
       
      +  .border-ring {
      +    border-color: var(--ring);
      +  }
      +
         .border-sidebar-border {
           border-color: var(--sidebar-border);
         }
      @@ -3494,6 +3498,10 @@
         }
       
         @media (hover: hover) {
      +    .hover\:border-ring:hover {
      +      border-color: var(--ring);
      +    }
      +
           .hover\:bg-accent\/50:hover {
             background-color: var(--accent);
           }
      diff --git a/module/services/frontend/code/packages/codefly-ui/stories/board.stories.tsx b/module/services/frontend/code/packages/codefly-ui/stories/board.stories.tsx
      new file mode 100644
      index 000000000..6be319f29
      --- /dev/null
      +++ b/module/services/frontend/code/packages/codefly-ui/stories/board.stories.tsx
      @@ -0,0 +1,122 @@
      +import { useState } from "react";
      +import { Board, type BoardColumn } from "../src/board/index.js";
      +import { Badge } from "../src/layout/index.js";
      +
      +export default { title: "Shared UI/Board" };
      +
      +interface Request {
      +	ref: string;
      +	name: string;
      +	stage: string;
      +	owner: string;
      +	opened: string;
      +}
      +
      +const COLUMNS: BoardColumn[] = [
      +	{ id: "open", label: "Open", empty: "Nothing waiting" },
      +	{ id: "review", label: "In review" },
      +	{ id: "done", label: "Done" },
      +	{ id: "refused", label: "Refused", empty: "Nothing refused" },
      +];
      +
      +const REQUESTS: Request[] = [
      +	{
      +		ref: "r-1",
      +		name: "Widen the export window",
      +		stage: "open",
      +		owner: "Jane Doe",
      +		opened: "2 Oct",
      +	},
      +	{
      +		ref: "r-2",
      +		name: "Second approver for a refund",
      +		stage: "open",
      +		owner: "Sam Roe",
      +		opened: "3 Oct",
      +	},
      +	{
      +		ref: "r-3",
      +		name: "Retire the legacy webhook",
      +		stage: "review",
      +		owner: "Jane Doe",
      +		opened: "28 Sep",
      +	},
      +	{
      +		ref: "r-4",
      +		name: "Regional split in the ledger",
      +		stage: "done",
      +		owner: "Alex Poe",
      +		opened: "12 Sep",
      +	},
      +];
      +
      +/**
      + * A move is reported, never committed: this story writes it itself — which is
      + * the consumer's job, and the only reason the cards appear to move at all.
      + */
      +function RequestsBoard() {
      +	const [requests, setRequests] = useState(REQUESTS);
      +	const [said, setSaid] = useState(null);
      +	return (
      +		
      + + items={requests} + columns={COLUMNS} + columnOf={(request) => request.stage} + idOf={(request) => request.ref} + labelOf={(request) => request.name} + searchText={(request) => `${request.name} ${request.owner}`} + searchLabel="Search by name or owner" + onOpen={(request) => setSaid(`Opened ${request.name}`)} + onMove={(request, column) => { + setSaid(`Moved ${request.name} to ${column.label}`); + setRequests((all) => + all.map((one) => + one.ref === request.ref ? { ...one, stage: column.id } : one, + ), + ); + }} + renderCard={(request) => ( + <> +
      {request.name}
      +
      + {request.owner} + {request.opened} +
      + + )} + /> +

      + {said ?? "Drag a card onto another column, or use its Move control."} +

      +
      + ); +} + +export const RequestsByStage = { render: () => }; + +/** A board nobody may move anything on: cards are read-only and do not drag. */ +export const ReadOnly = { + render: () => ( +
      + + items={REQUESTS} + columns={COLUMNS} + columnOf={(request) => request.stage} + idOf={(request) => request.ref} + labelOf={(request) => request.name} + renderCard={(request) => ( +
      {request.name}
      + )} + /> +
      + ), +}; diff --git a/module/services/frontend/code/packages/codefly-ui/stories/content.stories.tsx b/module/services/frontend/code/packages/codefly-ui/stories/content.stories.tsx index 0e54d33e9..1d6d1072f 100644 --- a/module/services/frontend/code/packages/codefly-ui/stories/content.stories.tsx +++ b/module/services/frontend/code/packages/codefly-ui/stories/content.stories.tsx @@ -133,3 +133,53 @@ export const DocumentPreview = { ), }; + +const VERSION = `--- +title: A proposal +owner: jane +--- + +# A module kit carries its own structure + +The host compiles **no stylesheet** for a remote, so a kit's layout is inline +and only colour comes from the host's tokens. See [the decision](../decisions/surfaces.md#option-c). + +- A paragraph is byte-exact. +- A bullet is not: markdown wrote it. + +\`\`\`ts +const exact = true; +\`\`\` +`; + +/** + * The same bytes, shown as a reader sees them and as the annotation layer reads + * them: every block carries the range of the version it came from, and the plain + * runs carry theirs character for character. + */ +export const SourceMappedDocument = { + render: () => { + const front = VERSION.indexOf("# A module kit"); + const body = VERSION.slice(front); + const base = new TextEncoder().encode(VERSION.slice(0, front)).length; + return ( +
      + ({ + href: `https://example.com/corpus/${href.replace(/^\.\.\//, "")}`, + external: true, + })} + > + {body} + +

      + Front matter is not rendered, and the offsets still name the version: + this body starts at byte {base}. +

      +
      + ); + }, +}; diff --git a/module/services/frontend/code/packages/codefly-ui/stories/stories.test.tsx b/module/services/frontend/code/packages/codefly-ui/stories/stories.test.tsx index 3027e4f3e..35ea36d30 100644 --- a/module/services/frontend/code/packages/codefly-ui/stories/stories.test.tsx +++ b/module/services/frontend/code/packages/codefly-ui/stories/stories.test.tsx @@ -3,14 +3,15 @@ import { resolveSkinRules } from "@codefly/saas-plugin-contract"; import { cleanup, render } from "@testing-library/react"; import { afterEach, expect, it } from "vitest"; import { assertSkinRules } from "../src/skin/rules.js"; +import * as board from "./board.stories"; import * as composites from "./composites.stories"; import * as content from "./content.stories"; -import * as foundations from "./foundations.stories"; import * as controls from "./controls.stories"; -import * as primitives from "./primitives.stories"; +import * as foundations from "./foundations.stories"; import * as metrics from "./metrics.stories"; -import * as tables from "./table.stories"; +import * as primitives from "./primitives.stories"; import * as semanticTables from "./semantic-table.stories"; +import * as tables from "./table.stories"; afterEach(cleanup); @@ -31,6 +32,7 @@ for (const [section, stories] of Object.entries({ semanticTables, composites, content, + board, })) { for (const [name, story] of Object.entries(stories)) { if (!("render" in story)) continue; diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index 7f9223792..4b35e622b 100644 --- a/module/services/frontend/code/packages/saas-ui/package.json +++ b/module/services/frontend/code/packages/saas-ui/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-ui", - "version": "0.12.0", + "version": "0.12.1", "description": "Reusable SaaS-domain frontend components (datasources, \u2026) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.", "repository": { "type": "git", diff --git a/module/services/frontend/code/scripts/test-published-ui.mjs b/module/services/frontend/code/scripts/test-published-ui.mjs index e6e699a7b..a71a4f9ce 100644 --- a/module/services/frontend/code/scripts/test-published-ui.mjs +++ b/module/services/frontend/code/scripts/test-published-ui.mjs @@ -78,11 +78,12 @@ import { CardRoot, CardContent, TabsRoot, TabsList, TabsTrigger, TabsContent, Pa import { MetricCard, MetricLineChart } from "@codefly-dev/ui/dashboard"; import { DataTable } from "@codefly-dev/ui/table"; import { Content } from "@codefly-dev/ui/content"; +import { Board } from "@codefly-dev/ui/board"; import { ConnectGitHubForm, type DatasourceClient } from "@codefly-dev/saas-ui"; const client: DatasourceClient = {listSources:async()=>[],addGitHubSource:async()=>{},syncSource:async()=>"example-job",deleteSource:async()=>{}}; const html=renderToStaticMarkup(OverviewWorkspace content); if (!html.includes("Workspace content") || !html.includes("Save") || !html.includes("Rendered")) throw new Error("Packed UI did not render"); -if (typeof DataTable !== "function" || typeof ConnectGitHubForm !== "function") throw new Error("Missing public component export"); +if (typeof DataTable !== "function" || typeof ConnectGitHubForm !== "function" || typeof Board !== "function") throw new Error("Missing public component export"); console.log("Packed UI declarations and generic consumer rendering passed"); `, diff --git a/module/services/frontend/code/src/solutions/SolutionOutlet.tsx b/module/services/frontend/code/src/solutions/SolutionOutlet.tsx index 180aa29a4..676de9ac7 100644 --- a/module/services/frontend/code/src/solutions/SolutionOutlet.tsx +++ b/module/services/frontend/code/src/solutions/SolutionOutlet.tsx @@ -4,6 +4,7 @@ import * as SaasSdk from "@codefly-dev/saas-sdk"; import * as SaasUi from "@codefly-dev/saas-ui"; import * as SaasUiSolution from "@codefly-dev/saas-ui/solution"; import * as CodeflyUi from "@codefly-dev/ui"; +import * as CodeflyBoard from "@codefly-dev/ui/board"; import * as CodeflyChat from "@codefly-dev/ui/chat"; import * as CodeflyDashboard from "@codefly-dev/ui/dashboard"; import * as CodeflyLayout from "@codefly-dev/ui/layout"; @@ -101,6 +102,11 @@ export const CODEFLY_KIT_SHARED = { lib: () => CodeflyContent, shareConfig: SEALED_SHARE_CONFIG, }, + "@codefly-dev/ui/board": { + version: CODEFLY_KIT_VERSION, + lib: () => CodeflyBoard, + shareConfig: SEALED_SHARE_CONFIG, + }, "@codefly-dev/ui/plugin-host": { version: CODEFLY_KIT_VERSION, lib: () => CodeflyPluginHost, diff --git a/module/services/frontend/code/src/solutions/__tests__/compatibility.test.ts b/module/services/frontend/code/src/solutions/__tests__/compatibility.test.ts index b2f3afb47..15a994678 100644 --- a/module/services/frontend/code/src/solutions/__tests__/compatibility.test.ts +++ b/module/services/frontend/code/src/solutions/__tests__/compatibility.test.ts @@ -96,6 +96,7 @@ describe("checkRuntimeCompatibility", () => { "@codefly-dev/ui": `^${CODEFLY_KIT_VERSION}`, "@codefly-dev/ui/layout": `^${CODEFLY_KIT_VERSION}`, "@codefly-dev/ui/table": `^${CODEFLY_KIT_VERSION}`, + "@codefly-dev/ui/board": `^${CODEFLY_KIT_VERSION}`, "@codefly-dev/saas-sdk": `^${CODEFLY_SAAS_SDK_VERSION}`, }, }, diff --git a/module/services/frontend/code/src/solutions/host-runtime.ts b/module/services/frontend/code/src/solutions/host-runtime.ts index c6dd5046f..4d0ff5896 100644 --- a/module/services/frontend/code/src/solutions/host-runtime.ts +++ b/module/services/frontend/code/src/solutions/host-runtime.ts @@ -63,6 +63,7 @@ export const HOST_SHARED_VERSIONS: Readonly> = { "@codefly-dev/ui/skin": CODEFLY_KIT_VERSION, "@codefly-dev/ui/table": CODEFLY_KIT_VERSION, "@codefly-dev/ui/content": CODEFLY_KIT_VERSION, + "@codefly-dev/ui/board": CODEFLY_KIT_VERSION, "@codefly-dev/ui/plugin-host": CODEFLY_KIT_VERSION, "@codefly-dev/ui/plugin-host/runtime": CODEFLY_KIT_VERSION, "@codefly-dev/ui/plugin-host/ui": CODEFLY_KIT_VERSION, diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 47b33693f..663512a51 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -3,6 +3,32 @@ "baselineStories": 0, "status": "Implementation in progress; story coverage and closure evidence incomplete", "families": [ + { + "source": "packages/codefly-ui/src/board/board.tsx", + "imports": [ + "@dnd-kit/core", + "react", + "../layout/badge.js", + "../layout/dropdown-menu.js", + "../layout/input.js", + "./cn.js" + ], + "owner": "@codefly-dev/ui/board", + "disposition": "already canonical", + "reason": "Owner package implementation: a collection in columns by a field, whose cards move between them. Replaces the board a consuming solution wrote for itself (module-saas-starter#1010).", + "stories": [ + { + "file": "packages/codefly-ui/stories/board.stories.tsx", + "export": "RequestsByStage" + }, + { + "file": "packages/codefly-ui/stories/board.stories.tsx", + "export": "ReadOnly" + } + ], + "coverage": "owner stories; the pointer drag is exercised against a stubbed layout, not a browser", + "remaining": "Verify the drag in a real browser under external skins, and the Move menu with a screen reader." + }, { "source": "packages/codefly-ui/src/chat/chat.tsx", "imports": [ @@ -136,9 +162,13 @@ "hast", "react", "react-markdown", - "remark-gfm", + "unified", "../layout/cn.js", "./code-block.js", + "./gfm.js", + "./links.js", + "./references.js", + "./source-offsets.js", "./url.js" ], "owner": "@codefly-dev/ui", @@ -152,6 +182,10 @@ { "file": "packages/codefly-ui/stories/content.stories.tsx", "export": "DocumentPreview" + }, + { + "file": "packages/codefly-ui/stories/content.stories.tsx", + "export": "SourceMappedDocument" } ], "coverage": "owner stories; browser/skin verification pending", @@ -936,7 +970,7 @@ ], "owner": "@codefly-dev/ui", "disposition": "already canonical", - "reason": "Determinate progress bar; the phase ladder a long-running job's view fills in. Deliberately has no indeterminate mode — every caller here has a real phase to name.", + "reason": "Determinate progress bar; the phase ladder a long-running job's view fills in. Deliberately has no indeterminate mode \u2014 every caller here has a real phase to name.", "stories": [ { "file": "packages/codefly-ui/stories/foundations.stories.tsx", @@ -5035,6 +5069,7 @@ "@codefly-dev/ui/layout": "packages/codefly-ui/src/layout/index.ts", "@codefly-dev/ui/table": "packages/codefly-ui/src/table/index.ts", "@codefly-dev/ui/content": "packages/codefly-ui/src/content/index.ts", - "@codefly-dev/saas-ui": "packages/saas-ui/src/index.ts" + "@codefly-dev/saas-ui": "packages/saas-ui/src/index.ts", + "@codefly-dev/ui/board": "packages/codefly-ui/src/board/index.ts" } }