astralyx-ui 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astralyx-ui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "homepage": "https://ui.astralyx.dev",
5
5
  "items": [
6
6
  {
@@ -1964,6 +1964,19 @@
1964
1964
  "lib-utils"
1965
1965
  ]
1966
1966
  },
1967
+ {
1968
+ "name": "knowledge-graph",
1969
+ "type": "registry:ui",
1970
+ "title": "Knowledge Graph",
1971
+ "description": "Entities and the named relations between them. Where a note graph answers what is connected, this answers how — and the edge label carries as much meaning as the nodes, so relations are labelled and directed.",
1972
+ "category": "Knowledge",
1973
+ "dependencies": [],
1974
+ "registryDependencies": [
1975
+ "lib-styles",
1976
+ "lib-use-force-graph",
1977
+ "lib-utils"
1978
+ ]
1979
+ },
1967
1980
  {
1968
1981
  "name": "label-picker",
1969
1982
  "type": "registry:ui",
@@ -2130,6 +2143,23 @@
2130
2143
  "lib-utils"
2131
2144
  ]
2132
2145
  },
2146
+ {
2147
+ "name": "markdown",
2148
+ "type": "registry:ui",
2149
+ "title": "Markdown",
2150
+ "description": "Rendered markdown with a switch to the source. It builds React nodes and never sets innerHTML, so a document you did not write cannot execute — and the RAW toggle is there because rendered output hides exactly what you look at when it renders wrongly.",
2151
+ "category": "Knowledge",
2152
+ "dependencies": [
2153
+ "lucide-react@^1.39.0"
2154
+ ],
2155
+ "registryDependencies": [
2156
+ "button",
2157
+ "code-block",
2158
+ "copy-button",
2159
+ "lib-styles",
2160
+ "lib-utils"
2161
+ ]
2162
+ },
2133
2163
  {
2134
2164
  "name": "market-table",
2135
2165
  "type": "registry:ui",
@@ -2575,6 +2605,19 @@
2575
2605
  "lib-utils"
2576
2606
  ]
2577
2607
  },
2608
+ {
2609
+ "name": "note-graph",
2610
+ "type": "registry:ui",
2611
+ "title": "Note Graph",
2612
+ "description": "A vault of linked notes laid out by force — the Obsidian-shaped view. Nodes are sized by how many links touch them, hovering dims everything outside the neighbourhood, and orphans are drawn hollow rather than hidden.",
2613
+ "category": "Knowledge",
2614
+ "dependencies": [],
2615
+ "registryDependencies": [
2616
+ "lib-styles",
2617
+ "lib-use-force-graph",
2618
+ "lib-utils"
2619
+ ]
2620
+ },
2578
2621
  {
2579
2622
  "name": "notification-inbox",
2580
2623
  "type": "registry:ui",
@@ -4761,6 +4804,13 @@
4761
4804
  "dependencies": [],
4762
4805
  "registryDependencies": []
4763
4806
  },
4807
+ {
4808
+ "name": "lib-use-force-graph",
4809
+ "type": "registry:lib",
4810
+ "title": "use-force-graph",
4811
+ "dependencies": [],
4812
+ "registryDependencies": []
4813
+ },
4764
4814
  {
4765
4815
  "name": "lib-use-uploads",
4766
4816
  "type": "registry:lib",
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "knowledge-graph",
3
+ "type": "registry:ui",
4
+ "title": "Knowledge Graph",
5
+ "description": "Entities and the named relations between them. Where a note graph answers what is connected, this answers how — and the edge label carries as much meaning as the nodes, so relations are labelled and directed.",
6
+ "category": "Knowledge",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-use-force-graph",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/knowledge-graph.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import {\n useId,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { useForceGraph } from '@/lib/use-force-graph'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Entities and the named relations between them.\n *\n * The distinction from `NoteGraph`, which shares the same layout: a note graph\n * answers *what is connected*, and every edge means the same thing — \"links\n * to\". A knowledge graph answers *how* things are connected, and the edge label\n * carries as much meaning as the nodes. \"Ada — **founded** → Astralyx\" and\n * \"Ada — **left** → Astralyx\" are the same two circles and the same line.\n *\n * So relations are labelled and **directed**, drawn with an arrowhead, because\n * `employs` and `employed by` are not the same fact and a knowledge graph that\n * loses direction is a set of vague associations.\n *\n * Entities are drawn as labelled pills rather than circles. A knowledge graph\n * is read entity-first — you are looking for *Astralyx*, not for a hub — and a\n * circle with a caption underneath makes you match shapes to text. This is\n * denser and it is why these graphs stay smaller than note vaults.\n */\nexport type Entity = {\n id: string\n label: string\n /** 'person', 'company', 'concept' — colours the pill and the legend. */\n type?: string\n}\n\nexport type Relation = {\n source: string\n target: string\n /** The verb. The reason this component exists. */\n label?: string\n}\n\ntype KnowledgeGraphProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n entities: Entity[]\n relations: Relation[]\n onSelect?: (entity: Entity) => void\n selectedId?: string\n height?: number | string\n /** Hide relation labels when the graph is dense enough that they collide. */\n showRelationLabels?: boolean\n colorFor?: (type: string | undefined) => string\n emptyLabel?: string\n label?: string\n}\n\nconst TYPE_COLORS = [\n 'var(--violet-soft)',\n 'var(--blue-soft)',\n 'var(--green-soft)',\n 'var(--amber-soft)',\n 'var(--cyan-soft)',\n]\nconst TYPE_INK = [\n 'var(--violet-soft-foreground)',\n 'var(--blue-soft-foreground)',\n 'var(--green-soft-foreground)',\n 'var(--amber-soft-foreground)',\n 'var(--cyan-soft-foreground)',\n]\n\nfunction KnowledgeGraph({\n entities,\n relations,\n onSelect,\n selectedId,\n height = 460,\n showRelationLabels = true,\n colorFor,\n emptyLabel = 'Nothing in this graph yet.',\n label = 'Knowledge graph',\n className,\n ...props\n}: KnowledgeGraphProps) {\n const boxRef = useRef<HTMLDivElement>(null)\n const [size, setSize] = useState({ width: 720, height: 460 })\n const [hovered, setHovered] = useState<string | null>(null)\n const dragging = useRef<string | null>(null)\n // `useId`, not a random string: a random id differs between the server\n // render and the hydrated one, which is a mismatch React will replace the\n // whole subtree over.\n const arrowId = `arrow-${useId().replace(/:/g, '')}`\n\n const links = useMemo(\n () => relations.map((relation) => ({ source: relation.source, target: relation.target })),\n [relations],\n )\n // Longer links than a note graph: the labels sit on them and need the room.\n const { positions, drag, release } = useForceGraph(entities, links, {\n ...size,\n linkDistance: 130,\n charge: 4200,\n })\n\n const types = useMemo(\n () => [...new Set(entities.map((entity) => entity.type).filter(Boolean))] as string[],\n [entities],\n )\n\n const fill = (type: string | undefined) =>\n colorFor?.(type) ?? (type ? TYPE_COLORS[types.indexOf(type) % TYPE_COLORS.length] : 'var(--muted)')\n const ink = (type: string | undefined) =>\n type ? TYPE_INK[types.indexOf(type) % TYPE_INK.length] : 'var(--muted-foreground)'\n\n const at = useMemo(() => new Map(positions.map((p) => [p.id, p])), [positions])\n\n const neighbours = useMemo(() => {\n const map = new Map<string, Set<string>>()\n for (const entity of entities) map.set(entity.id, new Set())\n for (const relation of relations) {\n map.get(relation.source)?.add(relation.target)\n map.get(relation.target)?.add(relation.source)\n }\n return map\n }, [entities, relations])\n\n const lit = (id: string) =>\n !hovered || hovered === id || neighbours.get(hovered)?.has(id) === true\n\n function pointerToBox(event: ReactPointerEvent) {\n const box = boxRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return { x: event.clientX - box.left, y: event.clientY - box.top }\n }\n\n return (\n <div\n data-slot=\"knowledge-graph\"\n ref={(node) => {\n boxRef.current = node\n if (node) {\n const box = node.getBoundingClientRect()\n if (Math.abs(box.width - size.width) > 1 || Math.abs(box.height - size.height) > 1) {\n setSize({ width: box.width, height: box.height })\n }\n }\n }}\n className={cn('relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n onPointerMove={(event) => {\n if (!dragging.current) return\n const point = pointerToBox(event)\n drag(dragging.current, point.x, point.y)\n }}\n onPointerUp={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n }}\n onPointerLeave={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n setHovered(null)\n }}\n {...props}\n >\n {entities.length === 0 ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : (\n <svg className=\"h-full w-full touch-none\" role=\"img\" aria-label={label}>\n <defs>\n {/* Scoped per instance: a document-wide id would be reused by a\n second graph on the page and inherit the first one's colour. */}\n <marker\n id={arrowId}\n viewBox=\"0 0 10 10\"\n refX=\"9\"\n refY=\"5\"\n markerWidth=\"5\"\n markerHeight=\"5\"\n orient=\"auto-start-reverse\"\n >\n <path d=\"M 0 0 L 10 5 L 0 10 z\" className=\"fill-border\" />\n </marker>\n </defs>\n\n <g>\n {relations.map((relation, index) => {\n const a = at.get(relation.source)\n const b = at.get(relation.target)\n if (!a || !b) return null\n const shown = lit(relation.source) && lit(relation.target)\n\n // Stop the line short of the pill so the arrowhead is not buried\n // underneath it.\n const dx = b.x - a.x\n const dy = b.y - a.y\n const distance = Math.hypot(dx, dy) || 1\n const inset = 44\n const x2 = b.x - (dx / distance) * inset\n const y2 = b.y - (dy / distance) * inset\n\n return (\n <g key={index} opacity={shown ? 1 : 0.1}>\n <line\n x1={a.x}\n y1={a.y}\n x2={x2}\n y2={y2}\n className=\"stroke-border\"\n strokeWidth={1.2}\n markerEnd={`url(#${arrowId})`}\n />\n {showRelationLabels && relation.label && (\n <text\n x={(a.x + x2) / 2}\n y={(a.y + y2) / 2 - 4}\n textAnchor=\"middle\"\n className=\"fill-muted-foreground pointer-events-none text-[9px]\"\n >\n {relation.label}\n </text>\n )}\n </g>\n )\n })}\n </g>\n\n <g>\n {entities.map((entity) => {\n const position = at.get(entity.id)\n if (!position) return null\n\n const shown = lit(entity.id)\n const selected = entity.id === selectedId\n // Enough for the label; SVG cannot measure text before layout, so\n // it is estimated from the character count.\n const width = Math.max(56, entity.label.length * 6.6 + 18)\n\n return (\n <g\n key={entity.id}\n transform={`translate(${position.x} ${position.y})`}\n opacity={shown ? 1 : 0.12}\n className={cn(onSelect && 'cursor-pointer')}\n onPointerDown={(event) => {\n event.stopPropagation()\n dragging.current = entity.id\n }}\n onPointerEnter={() => setHovered(entity.id)}\n onClick={() => onSelect?.(entity)}\n >\n <rect\n x={-width / 2}\n y={-13}\n width={width}\n height={26}\n rx={13}\n fill={fill(entity.type)}\n stroke={selected ? 'var(--primary)' : 'transparent'}\n strokeWidth={selected ? 2 : 0}\n />\n <text\n textAnchor=\"middle\"\n dominantBaseline=\"central\"\n className=\"pointer-events-none text-[10px] font-medium\"\n fill={ink(entity.type)}\n >\n {entity.label}\n </text>\n <title>\n {entity.label}\n {entity.type ? ` — ${entity.type}` : ''}\n </title>\n </g>\n )\n })}\n </g>\n </svg>\n )}\n\n {types.length > 0 && (\n <ul className=\"absolute start-3 bottom-3 flex list-none flex-wrap gap-x-3 gap-y-1\">\n {types.map((type) => (\n <li key={type} className=\"text-muted-foreground flex items-center gap-1.5 text-[11px]\">\n <span\n aria-hidden=\"true\"\n className=\"size-2 shrink-0 rounded-full\"\n style={{ background: ink(type) }}\n />\n {type}\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { KnowledgeGraph }\nexport type { KnowledgeGraphProps }\n"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "lib-use-force-graph",
3
+ "type": "registry:lib",
4
+ "title": "use-force-graph",
5
+ "dependencies": [],
6
+ "registryDependencies": [],
7
+ "files": [
8
+ {
9
+ "path": "lib/use-force-graph.ts",
10
+ "type": "registry:lib",
11
+ "content": "import { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * A force-directed layout, shared by the graph components.\n *\n * Three forces, which is all a readable graph needs: every pair of nodes pushes\n * apart, every link pulls together, and a weak pull toward the centre stops\n * disconnected components drifting off screen forever.\n *\n * **It stops.** `alpha` decays and the loop ends once motion falls below a\n * threshold, because a graph that jitters permanently is unreadable, burns\n * battery, and never lets you click anything. Dragging or changing the data\n * reheats it.\n *\n * **Repulsion is O(n²)** — no quadtree. At the sizes a graph stays legible at,\n * a few hundred nodes, that is a fraction of a millisecond per tick, and a\n * Barnes–Hut tree is a lot of code to maintain for a component that becomes\n * unreadable long before it becomes slow.\n *\n * **Initial positions are deterministic**, placed on a phyllotaxis spiral\n * rather than at random. Random seeding makes a server-rendered graph and its\n * hydrated counterpart disagree, and makes every reload a different picture of\n * the same data.\n */\nexport type ForceNode = {\n id: string\n /** Pins the node. Dragged nodes are pinned while held. */\n fixed?: boolean\n}\n\nexport type ForceLink = { source: string; target: string }\n\nexport type Positioned = { id: string; x: number; y: number }\n\ntype Options = {\n /** Pixels. The layout is centred in this box. */\n width: number\n height: number\n /** Resting length of a link. Defaults to a value derived from the box. */\n linkDistance?: number\n /** How hard nodes push each other apart. Defaults to area per node. */\n charge?: number\n /** Pull toward the centre. Small, or everything collapses into a ball. */\n gravity?: number\n /** Below this average speed the simulation stops. */\n settleAt?: number\n /** Keep-out margin at the edges, in pixels. Room for radii and labels. */\n padding?: number\n}\n\ntype Body = { id: string; x: number; y: number; vx: number; vy: number; fixed: boolean }\n\n/** Deterministic, evenly spread starting positions — no RNG. */\nfunction seedPositions(count: number, width: number, height: number): Body[] {\n const golden = Math.PI * (3 - Math.sqrt(5))\n const radius = Math.min(width, height) * 0.35\n\n return Array.from({ length: count }, (_, index) => {\n const distance = radius * Math.sqrt((index + 0.5) / count)\n const angle = index * golden\n return {\n id: '',\n x: width / 2 + Math.cos(angle) * distance,\n y: height / 2 + Math.sin(angle) * distance,\n vx: 0,\n vy: 0,\n fixed: false,\n }\n })\n}\n\nexport function useForceGraph(\n nodes: ForceNode[],\n links: ForceLink[],\n { width, height, linkDistance, charge, gravity = 0.006, settleAt = 0.06, padding = 30 }: Options,\n) {\n /**\n * Both defaults are derived from the box, because a graph laid out with fixed\n * pixel constants is tuned for exactly one canvas size and wrong at every\n * other one — the same vault collapsed into the middle third of a small\n * preview and spilled off the edges of a large one.\n *\n * The **link distance** is what actually sets the size of the drawing. At the\n * separations these graphs settle at, the spring term is roughly fifty times\n * the repulsion term, so raising the charge to spread a cluster does almost\n * nothing; the resting length of a link is the lever. Dividing the shorter\n * side by √n is the usual area-per-node argument: n nodes at spacing d need\n * about n·d² of room.\n */\n const span = Math.min(width, height)\n const rest =\n linkDistance ?? Math.max(48, Math.min(170, (span / Math.sqrt(Math.max(2, nodes.length))) * 1.15))\n /** Repulsion, which matters at short range — it is what stops nodes piling up. */\n const push = charge ?? Math.max(600, ((width * height) / Math.max(1, nodes.length)) * 0.34)\n const [positions, setPositions] = useState<Positioned[]>([])\n const bodies = useRef<Map<string, Body>>(new Map())\n const alpha = useRef(1)\n const frame = useRef(0)\n\n // Identity of the graph, not of the arrays: a caller building `nodes` inline\n // would otherwise reseed the layout on every render.\n const shape = `${nodes.map((node) => node.id).join(',')}|${links\n .map((link) => `${link.source}>${link.target}`)\n .join(',')}`\n\n // Latest values, read inside the effects. `shape` is what actually decides\n // when the layout must restart; depending on the arrays themselves would\n // reseed on every render for any caller that builds them inline.\n const latest = useRef({ nodes, links })\n latest.current = { nodes, links }\n\n const reheat = useCallback(() => {\n alpha.current = 1\n }, [])\n\n useEffect(() => {\n const current = latest.current.nodes\n const seeds = seedPositions(current.length, width, height)\n const next = new Map<string, Body>()\n\n current.forEach((node, index) => {\n // Keep a node where it already was, so adding one does not scatter the\n // graph someone has just finished reading.\n const existing = bodies.current.get(node.id)\n next.set(node.id, existing ?? { ...seeds[index], id: node.id })\n })\n\n bodies.current = next\n alpha.current = 1\n setPositions([...next.values()].map(({ id, x, y }) => ({ id, x, y })))\n }, [shape, width, height])\n\n useEffect(() => {\n let running = true\n\n const tick = () => {\n if (!running) return\n\n const list = [...bodies.current.values()]\n if (list.length === 0) return\n\n // Repulsion, every pair once.\n for (let i = 0; i < list.length; i++) {\n for (let j = i + 1; j < list.length; j++) {\n const a = list[i]\n const b = list[j]\n let dx = b.x - a.x\n let dy = b.y - a.y\n let distanceSquared = dx * dx + dy * dy\n\n // Two nodes exactly on top of each other have no direction to\n // separate along, so nudge them apart deterministically.\n if (distanceSquared < 0.01) {\n dx = (i - j) * 0.5\n dy = 0.5\n distanceSquared = dx * dx + dy * dy\n }\n\n const force = push / distanceSquared\n const distance = Math.sqrt(distanceSquared)\n const fx = (dx / distance) * force\n const fy = (dy / distance) * force\n\n a.vx -= fx\n a.vy -= fy\n b.vx += fx\n b.vy += fy\n }\n }\n\n // Springs.\n for (const link of latest.current.links) {\n const a = bodies.current.get(link.source)\n const b = bodies.current.get(link.target)\n if (!a || !b) continue\n\n const dx = b.x - a.x\n const dy = b.y - a.y\n const distance = Math.sqrt(dx * dx + dy * dy) || 0.01\n const force = (distance - rest) * 0.05\n const fx = (dx / distance) * force\n const fy = (dy / distance) * force\n\n a.vx += fx\n a.vy += fy\n b.vx -= fx\n b.vy -= fy\n }\n\n // Gravity, then integrate.\n let motion = 0\n for (const body of list) {\n if (body.fixed) {\n body.vx = 0\n body.vy = 0\n continue\n }\n\n body.vx += (width / 2 - body.x) * gravity\n body.vy += (height / 2 - body.y) * gravity\n\n body.vx *= 0.82\n body.vy *= 0.82\n\n // Clamped to the box. The container clips, so a node pushed past the\n // edge is not merely off-centre — it is invisible, and so is every link\n // that ends there. Sizing the layout to the box gets it close; this is\n // what guarantees it, and it leaves room for the radius and the label.\n body.x = Math.min(Math.max(padding, body.x + body.vx * alpha.current), width - padding)\n body.y = Math.min(Math.max(padding, body.y + body.vy * alpha.current), height - padding)\n motion += Math.abs(body.vx) + Math.abs(body.vy)\n }\n\n alpha.current *= 0.985\n setPositions(list.map(({ id, x, y }) => ({ id, x, y })))\n\n // Stop when it has settled. A graph that never stops moving cannot be\n // read and cannot be clicked.\n if (motion / list.length > settleAt && alpha.current > 0.005) {\n frame.current = requestAnimationFrame(tick)\n }\n }\n\n frame.current = requestAnimationFrame(tick)\n return () => {\n running = false\n cancelAnimationFrame(frame.current)\n }\n }, [shape, width, height, rest, push, gravity, settleAt, padding])\n\n /** Move a node under the pointer and hold it there. */\n const drag = useCallback(\n (id: string, x: number, y: number) => {\n const body = bodies.current.get(id)\n if (!body) return\n body.x = Math.min(Math.max(padding, x), width - padding)\n body.y = Math.min(Math.max(padding, y), height - padding)\n body.fixed = true\n alpha.current = Math.max(alpha.current, 0.4)\n setPositions([...bodies.current.values()].map((b) => ({ id: b.id, x: b.x, y: b.y })))\n },\n [width, height, padding],\n )\n\n const release = useCallback((id: string) => {\n const body = bodies.current.get(id)\n if (body) body.fixed = false\n }, [])\n\n return { positions, drag, release, reheat }\n}\n"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "markdown",
3
+ "type": "registry:ui",
4
+ "title": "Markdown",
5
+ "description": "Rendered markdown with a switch to the source. It builds React nodes and never sets innerHTML, so a document you did not write cannot execute — and the RAW toggle is there because rendered output hides exactly what you look at when it renders wrongly.",
6
+ "category": "Knowledge",
7
+ "dependencies": [
8
+ "lucide-react@^1.39.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "button",
12
+ "code-block",
13
+ "copy-button",
14
+ "lib-styles",
15
+ "lib-utils"
16
+ ],
17
+ "files": [
18
+ {
19
+ "path": "components/ui/markdown.tsx",
20
+ "type": "registry:ui",
21
+ "content": "import { useMemo, useState, type ComponentProps, type ReactNode } from 'react'\nimport { Code2, Eye } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { CodeBlock } from '@/components/ui/code-block'\nimport { CopyButton } from '@/components/ui/copy-button'\nimport { focusRing, radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * Rendered markdown, with a switch to the source.\n *\n * **It builds React nodes. It never sets `innerHTML`.** Almost every small\n * markdown renderer converts to an HTML string and injects it, which turns any\n * document you did not write — a README from a registry, a model's output, a\n * comment — into script execution. Producing elements means there is no string\n * for a payload to survive in, and the one place raw HTML could appear in\n * markdown is passed through as text instead.\n *\n * **The RAW toggle is the point of the component.** Rendered markdown hides the\n * difference between a hard line break and a soft one, between `*` and `_`,\n * between a real table and an aligned one — and those are exactly what someone\n * is looking for when a document renders wrongly. Source is one click away and\n * copyable.\n *\n * The supported subset is what documents actually contain: ATX headings, bold,\n * italic, inline code, links, images, fenced and indented code, ordered and\n * unordered lists, blockquotes, horizontal rules, and tables. Setext headings,\n * reference links, footnotes and nested blockquotes are not handled — a\n * complete CommonMark implementation is a library, not a component, and this\n * one is honest about where it stops.\n */\ntype MarkdownProps = Omit<ComponentProps<'div'>, 'children'> & {\n children: string\n /** Start on the source rather than the preview. */\n defaultRaw?: boolean\n /** Hide the toggle entirely for a fixed, rendered-only view. */\n toggle?: boolean\n previewLabel?: string\n rawLabel?: string\n copyLabel?: string\n /** Trailing slot in the toolbar. */\n actions?: ReactNode\n /** Rendered when the source is empty. */\n emptyLabel?: string\n}\n\n/* ----------------------------------------------------------------- inline */\n\nconst INLINE = /(`[^`]+`)|(\\*\\*[^*]+\\*\\*)|(__[^_]+__)|(\\*[^*]+\\*)|(_[^_]+_)|(~~[^~]+~~)|(!?\\[[^\\]]*\\]\\([^)]+\\))/\n\n/**\n * Inline spans, as nodes.\n *\n * Split rather than replaced, so a link's text can never be re-parsed as\n * markup and a payload has no string to hide in.\n */\nfunction inline(text: string, keyPrefix = ''): ReactNode[] {\n const out: ReactNode[] = []\n let rest = text\n let key = 0\n\n while (rest) {\n const match = INLINE.exec(rest)\n if (!match || match.index === undefined) {\n out.push(rest)\n break\n }\n\n if (match.index > 0) out.push(rest.slice(0, match.index))\n const token = match[0]\n const id = `${keyPrefix}-${key++}`\n\n if (token.startsWith('`')) {\n out.push(\n <code key={id} className={cn('bg-muted px-1 py-0.5 font-mono text-[0.9em]', radius.xs)}>\n {token.slice(1, -1)}\n </code>,\n )\n } else if (token.startsWith('**') || token.startsWith('__')) {\n out.push(<strong key={id}>{token.slice(2, -2)}</strong>)\n } else if (token.startsWith('~~')) {\n out.push(\n <s key={id} className=\"text-muted-foreground\">\n {token.slice(2, -2)}\n </s>,\n )\n } else if (token.startsWith('![')) {\n const [, alt, src] = /!\\[([^\\]]*)\\]\\(([^)]+)\\)/.exec(token) ?? []\n out.push(\n <img key={id} src={src} alt={alt ?? ''} className={cn('my-2 max-w-full', radius.control)} />,\n )\n } else if (token.startsWith('[')) {\n const [, label, href] = /\\[([^\\]]*)\\]\\(([^)]+)\\)/.exec(token) ?? []\n // `javascript:` in a link is the other half of the injection this\n // component refuses; only http(s), mailto and relative URLs survive.\n const safe = href && /^(https?:|mailto:|[./#])/i.test(href) ? href : undefined\n out.push(\n safe ? (\n <a\n key={id}\n href={safe}\n className={cn('underline underline-offset-2', focusRing)}\n rel=\"noreferrer noopener\"\n target={safe.startsWith('http') ? '_blank' : undefined}\n >\n {label}\n </a>\n ) : (\n <span key={id}>{label}</span>\n ),\n )\n } else {\n out.push(<em key={id}>{token.slice(1, -1)}</em>)\n }\n\n rest = rest.slice(match.index + token.length)\n }\n\n return out\n}\n\n/* ------------------------------------------------------------------ block */\n\nfunction render(source: string): ReactNode[] {\n const lines = source.replace(/\\r\\n/g, '\\n').split('\\n')\n const out: ReactNode[] = []\n let index = 0\n let key = 0\n\n while (index < lines.length) {\n const line = lines[index]\n\n if (!line.trim()) {\n index++\n continue\n }\n\n // Fenced code. Taken verbatim — nothing inside is parsed.\n const fence = /^```(\\w*)/.exec(line)\n if (fence) {\n const body: string[] = []\n index++\n while (index < lines.length && !/^```/.test(lines[index])) body.push(lines[index++])\n index++\n out.push(\n <CodeBlock\n key={key++}\n code={body.join('\\n')}\n language={(fence[1] || 'text') as never}\n header={false}\n className=\"my-3\"\n />,\n )\n continue\n }\n\n const heading = /^(#{1,6})\\s+(.*)$/.exec(line)\n if (heading) {\n const level = heading[1].length\n const Tag = `h${level}` as 'h1'\n const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm']\n out.push(\n <Tag\n key={key++}\n className={cn('mt-5 mb-2 font-semibold tracking-tight first:mt-0', sizes[level - 1])}\n >\n {inline(heading[2], `h${key}`)}\n </Tag>,\n )\n index++\n continue\n }\n\n if (/^(---|\\*\\*\\*|___)\\s*$/.test(line)) {\n out.push(<hr key={key++} className=\"border-border my-5\" />)\n index++\n continue\n }\n\n if (/^>\\s?/.test(line)) {\n const body: string[] = []\n while (index < lines.length && /^>\\s?/.test(lines[index])) {\n body.push(lines[index++].replace(/^>\\s?/, ''))\n }\n out.push(\n <blockquote\n key={key++}\n className=\"border-border text-muted-foreground my-3 border-s-2 ps-4 italic\"\n >\n {inline(body.join(' '), `q${key}`)}\n </blockquote>,\n )\n continue\n }\n\n // Tables: a header row, a delimiter row, then body rows.\n if (line.includes('|') && /^\\s*\\|?[\\s:-]+\\|[\\s:|-]*$/.test(lines[index + 1] ?? '')) {\n const cells = (row: string) =>\n row.replace(/^\\||\\|$/g, '').split('|').map((cell) => cell.trim())\n const head = cells(line)\n index += 2\n const rows: string[][] = []\n while (index < lines.length && lines[index].includes('|')) rows.push(cells(lines[index++]))\n\n out.push(\n <div key={key++} className=\"my-3 overflow-x-auto\">\n <table className=\"w-full border-collapse text-left text-sm\">\n <thead>\n <tr className=\"border-border border-b\">\n {head.map((cell, i) => (\n <th key={i} className=\"px-3 py-1.5 font-medium\">\n {inline(cell, `th${i}`)}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((row, r) => (\n <tr key={r} className=\"border-border/60 border-b last:border-b-0\">\n {row.map((cell, c) => (\n <td key={c} className=\"text-muted-foreground px-3 py-1.5\">\n {inline(cell, `td${r}-${c}`)}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n </div>,\n )\n continue\n }\n\n const bullet = /^\\s*([-*+])\\s+/.test(line)\n const numbered = /^\\s*\\d+\\.\\s+/.test(line)\n if (bullet || numbered) {\n const items: string[] = []\n const matches = (row: string) =>\n bullet ? /^\\s*([-*+])\\s+/.test(row) : /^\\s*\\d+\\.\\s+/.test(row)\n\n while (index < lines.length && matches(lines[index])) {\n items.push(lines[index++].replace(/^\\s*([-*+]|\\d+\\.)\\s+/, ''))\n }\n\n const Tag = bullet ? 'ul' : 'ol'\n out.push(\n <Tag\n key={key++}\n className={cn('my-3 space-y-1 ps-5', bullet ? 'list-disc' : 'list-decimal')}\n >\n {items.map((item, i) => (\n <li key={i} className=\"text-sm leading-relaxed\">\n {inline(item, `li${i}`)}\n </li>\n ))}\n </Tag>,\n )\n continue\n }\n\n // Paragraph: consecutive non-blank lines that start nothing else.\n const body: string[] = []\n while (\n index < lines.length &&\n lines[index].trim() &&\n !/^(#{1,6}\\s|>|```|\\s*([-*+]|\\d+\\.)\\s|(---|\\*\\*\\*|___)\\s*$)/.test(lines[index])\n ) {\n body.push(lines[index++])\n }\n out.push(\n <p key={key++} className=\"my-3 text-sm leading-relaxed\">\n {inline(body.join(' '), `p${key}`)}\n </p>,\n )\n }\n\n return out\n}\n\nfunction Markdown({\n children,\n defaultRaw = false,\n toggle = true,\n previewLabel = 'Preview',\n rawLabel = 'Raw',\n copyLabel = 'Copy markdown',\n actions,\n emptyLabel = 'Nothing to show.',\n className,\n ...props\n}: MarkdownProps) {\n const [raw, setRaw] = useState(defaultRaw)\n const nodes = useMemo(() => render(children), [children])\n\n return (\n <div\n data-slot=\"markdown\"\n data-view={raw ? 'raw' : 'preview'}\n className={cn(surface, radius.surface, 'overflow-hidden', className)}\n {...props}\n >\n {(toggle || actions) && (\n <div className=\"border-border bg-muted/40 flex items-center gap-2 border-b px-3 py-2\">\n {toggle && (\n <Button\n variant=\"ghost\"\n size=\"sm\"\n aria-pressed={raw}\n onClick={() => setRaw((current) => !current)}\n >\n {raw ? <Eye /> : <Code2 />}\n {raw ? previewLabel : rawLabel}\n </Button>\n )}\n <span className=\"flex-1\" />\n {actions}\n <CopyButton value={children} label={copyLabel} />\n </div>\n )}\n\n {!children.trim() ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : raw ? (\n // The source, exactly as given — the reason the toggle exists.\n <pre className=\"text-foreground/85 overflow-x-auto p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap\">\n {children}\n </pre>\n ) : (\n <div className=\"p-4\">{nodes}</div>\n )}\n </div>\n )\n}\n\nexport { Markdown, render as renderMarkdown }\nexport type { MarkdownProps }\n"
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "note-graph",
3
+ "type": "registry:ui",
4
+ "title": "Note Graph",
5
+ "description": "A vault of linked notes laid out by force — the Obsidian-shaped view. Nodes are sized by how many links touch them, hovering dims everything outside the neighbourhood, and orphans are drawn hollow rather than hidden.",
6
+ "category": "Knowledge",
7
+ "dependencies": [],
8
+ "registryDependencies": [
9
+ "lib-styles",
10
+ "lib-use-force-graph",
11
+ "lib-utils"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "components/ui/note-graph.tsx",
16
+ "type": "registry:ui",
17
+ "content": "import {\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type PointerEvent as ReactPointerEvent,\n} from 'react'\nimport { useForceGraph, type ForceLink } from '@/lib/use-force-graph'\nimport { radius, surface } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A vault of linked notes, laid out by force.\n *\n * The Obsidian-shaped view: notes as circles, links as lines, and the thing you\n * are looking for found by shape rather than by name.\n *\n * **Size is degree, not recency or length.** A hub with thirty backlinks is the\n * note the vault is organised around, and it should be the one your eye lands\n * on. Sizing by word count or edit date produces a picture of your typing\n * habits instead of your structure.\n *\n * **Orphans are drawn, and drawn differently.** A note nothing links to is the\n * most actionable thing this view can surface — it is either miscategorised or\n * forgotten — so hiding unconnected nodes to tidy the picture removes its main\n * use. They sit in a ring at the edge, hollow.\n *\n * **Hovering dims everything except the neighbourhood.** At a few hundred nodes\n * the hairball is unreadable at rest; focus is what makes it legible, and it\n * costs one CSS class rather than a second layout.\n *\n * The layout stops once it settles — see `useForceGraph`. Dragging a note pins\n * it, which is how you pull a cluster apart to read it.\n */\nexport type Note = {\n id: string\n title: string\n /** Colours the node. A folder, a tag, whatever you group by. */\n group?: string\n /** Overrides the degree-derived size. */\n size?: number\n}\n\ntype NoteGraphProps = Omit<ComponentProps<'div'>, 'onSelect'> & {\n notes: Note[]\n links: ForceLink[]\n onSelect?: (note: Note) => void\n selectedId?: string\n height?: number | string\n /** Show every title, rather than only hubs and the hovered neighbourhood. */\n showAllLabels?: boolean\n /** Degree at or above which a title is always drawn. */\n labelFrom?: number\n /** Map a group to a CSS colour. */\n colorFor?: (group: string | undefined) => string\n orphanLabel?: string\n emptyLabel?: string\n label?: string\n}\n\nconst GROUP_COLORS = [\n 'var(--blue-soft-foreground)',\n 'var(--violet-soft-foreground)',\n 'var(--cyan-soft-foreground)',\n 'var(--amber-soft-foreground)',\n 'var(--green-soft-foreground)',\n]\n\nfunction NoteGraph({\n notes,\n links,\n onSelect,\n selectedId,\n height = 460,\n showAllLabels = false,\n labelFrom = 4,\n colorFor,\n orphanLabel = 'no links',\n emptyLabel = 'This vault has no notes.',\n label = 'Note graph',\n className,\n ...props\n}: NoteGraphProps) {\n const boxRef = useRef<HTMLDivElement>(null)\n const [size, setSize] = useState({ width: 720, height: 460 })\n const [hovered, setHovered] = useState<string | null>(null)\n const dragging = useRef<string | null>(null)\n\n const { positions, drag, release } = useForceGraph(notes, links, size)\n\n /** How many links touch each note, and who its neighbours are. */\n const { degree, neighbours } = useMemo(() => {\n const degree = new Map<string, number>()\n const neighbours = new Map<string, Set<string>>()\n\n for (const note of notes) {\n degree.set(note.id, 0)\n neighbours.set(note.id, new Set())\n }\n for (const link of links) {\n degree.set(link.source, (degree.get(link.source) ?? 0) + 1)\n degree.set(link.target, (degree.get(link.target) ?? 0) + 1)\n neighbours.get(link.source)?.add(link.target)\n neighbours.get(link.target)?.add(link.source)\n }\n return { degree, neighbours }\n }, [notes, links])\n\n const groups = useMemo(\n () => [...new Set(notes.map((note) => note.group).filter(Boolean))] as string[],\n [notes],\n )\n\n const colour = (group: string | undefined) =>\n colorFor?.(group) ??\n (group ? GROUP_COLORS[groups.indexOf(group) % GROUP_COLORS.length] : 'var(--muted-foreground)')\n\n const at = useMemo(\n () => new Map(positions.map((position) => [position.id, position])),\n [positions],\n )\n\n /** Dimmed unless it is the hovered note or one of its neighbours. */\n const lit = (id: string) =>\n !hovered || hovered === id || neighbours.get(hovered)?.has(id) === true\n\n function pointerToBox(event: ReactPointerEvent) {\n const box = boxRef.current?.getBoundingClientRect()\n if (!box) return { x: 0, y: 0 }\n return { x: event.clientX - box.left, y: event.clientY - box.top }\n }\n\n return (\n <div\n data-slot=\"note-graph\"\n ref={(node) => {\n boxRef.current = node\n if (node) {\n const box = node.getBoundingClientRect()\n // Only on a real change, or this loops.\n if (Math.abs(box.width - size.width) > 1 || Math.abs(box.height - size.height) > 1) {\n setSize({ width: box.width, height: box.height })\n }\n }\n }}\n className={cn('relative overflow-hidden', surface, radius.surface, className)}\n style={{ height }}\n onPointerMove={(event) => {\n if (!dragging.current) return\n const point = pointerToBox(event)\n drag(dragging.current, point.x, point.y)\n }}\n onPointerUp={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n }}\n onPointerLeave={() => {\n if (dragging.current) release(dragging.current)\n dragging.current = null\n setHovered(null)\n }}\n {...props}\n >\n {notes.length === 0 ? (\n <p className=\"text-muted-foreground p-4 text-xs\">{emptyLabel}</p>\n ) : (\n <svg className=\"h-full w-full touch-none\" role=\"img\" aria-label={label}>\n <g>\n {links.map((link, index) => {\n const a = at.get(link.source)\n const b = at.get(link.target)\n if (!a || !b) return null\n const shown = lit(link.source) && lit(link.target)\n\n return (\n <line\n key={index}\n x1={a.x}\n y1={a.y}\n x2={b.x}\n y2={b.y}\n className=\"stroke-border\"\n strokeWidth={hovered && shown ? 1.4 : 1}\n opacity={shown ? 0.9 : 0.12}\n />\n )\n })}\n </g>\n\n <g>\n {notes.map((note) => {\n const position = at.get(note.id)\n if (!position) return null\n\n const links = degree.get(note.id) ?? 0\n // Degree, damped: a hub with thirty backlinks should read as\n // bigger than one with three without swamping the canvas.\n const r = note.size ?? Math.min(18, 4 + Math.sqrt(links) * 2.6)\n const orphan = links === 0\n const shown = lit(note.id)\n const selected = note.id === selectedId\n const labelled =\n showAllLabels || selected || hovered === note.id || links >= labelFrom\n\n return (\n <g\n key={note.id}\n transform={`translate(${position.x} ${position.y})`}\n opacity={shown ? 1 : 0.15}\n className={cn(onSelect && 'cursor-pointer')}\n onPointerDown={(event) => {\n event.stopPropagation()\n dragging.current = note.id\n ;(event.target as Element).releasePointerCapture?.(event.pointerId)\n }}\n onPointerEnter={() => setHovered(note.id)}\n onClick={() => onSelect?.(note)}\n >\n <circle\n r={r}\n // Hollow for an orphan: the note nothing links to is the\n // most actionable thing here, so it must not blend in.\n fill={orphan ? 'transparent' : colour(note.group)}\n stroke={\n selected\n ? 'var(--primary)'\n : orphan\n ? colour(note.group)\n : 'transparent'\n }\n strokeWidth={selected ? 2.5 : orphan ? 1.5 : 0}\n strokeDasharray={orphan && !selected ? '3 3' : undefined}\n />\n {labelled && (\n <text\n y={r + 12}\n textAnchor=\"middle\"\n className=\"fill-foreground pointer-events-none text-[10px]\"\n >\n {note.title}\n </text>\n )}\n <title>\n {note.title}\n {orphan ? ` — ${orphanLabel}` : ` — ${links}`}\n </title>\n </g>\n )\n })}\n </g>\n </svg>\n )}\n\n {groups.length > 0 && (\n <ul className=\"absolute start-3 bottom-3 flex list-none flex-wrap gap-x-3 gap-y-1\">\n {groups.map((group) => (\n <li key={group} className=\"text-muted-foreground flex items-center gap-1.5 text-[11px]\">\n <span\n aria-hidden=\"true\"\n className=\"size-2 shrink-0 rounded-full\"\n style={{ background: colour(group) }}\n />\n {group}\n </li>\n ))}\n </ul>\n )}\n </div>\n )\n}\n\nexport { NoteGraph }\nexport type { NoteGraphProps }\n"
18
+ }
19
+ ]
20
+ }