Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions module/contracts/behavior.codefly.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions module/services/frontend/code/UI_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions module/services/frontend/code/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions module/services/frontend/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ 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
```

`content` sits at the rank of `charts`: it composes layout atoms, and the
composites compose it (`<Chat>` 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.

Expand Down
3 changes: 2 additions & 1 deletion module/services/frontend/code/packages/codefly-ui/CATALOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 129 additions & 2 deletions module/services/frontend/code/packages/codefly-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<Markdown>` 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

Expand Down Expand Up @@ -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 |
Expand All @@ -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`,
Expand All @@ -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
Expand Down Expand Up @@ -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
<Markdown
headingLevel={2}
sourceOffsets // mark what was rendered
sourceStart={utf8Length(version.slice(0, front))}
resolveLink={(href) => resolve(href)} // where a link in this corpus goes
>
{version.slice(front)}
</Markdown>
```

| Attribute | On | Means |
| --- | --- | --- |
| `data-source-start`, `data-source-end` | every block, and a `<span>` 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 `<pre>` carries the
whole fence and is never exact, while the `<code>` 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";

<Board
items={requests}
columns={[{ id: "open", label: "Open", empty: "Nothing waiting" }, …]}
columnOf={(request) => request.stage} // the field it groups by
idOf={(request) => request.ref}
labelOf={(request) => request.name} // what to call one, in a sentence
renderCard={(request) => <RequestCard request={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

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number> = {
Expand All @@ -25,6 +26,7 @@ const TIER_RANK: Record<string, number> = {
dashboard: 3,
chat: 3,
table: 3,
board: 3,
form: 3,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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");
Expand Down
Loading
Loading