@refraction-ui/astro 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/astro-audio-room/AudioRoom.astro +81 -0
  2. package/dist/astro-audio-room/index.ts +1 -0
  3. package/dist/astro-call-controls/CallControls.astro +57 -0
  4. package/dist/astro-call-controls/index.ts +1 -0
  5. package/dist/astro-checklist/Checklist.astro +76 -0
  6. package/dist/astro-checklist/index.ts +1 -0
  7. package/dist/astro-editor-status-bar/EditorStatusBar.astro +92 -0
  8. package/dist/astro-editor-status-bar/index.ts +1 -0
  9. package/dist/astro-editor-tabs/EditorTabs.astro +74 -0
  10. package/dist/astro-editor-tabs/index.ts +1 -0
  11. package/dist/astro-floating-reactions/FloatingReactions.astro +56 -0
  12. package/dist/astro-floating-reactions/index.ts +1 -0
  13. package/dist/astro-flow-editor/FlowEditor.astro +99 -0
  14. package/dist/astro-flow-editor/index.ts +3 -0
  15. package/dist/astro-graph-view/GraphView.astro +109 -0
  16. package/dist/astro-graph-view/index.ts +1 -0
  17. package/dist/astro-infinite-canvas/InfiniteCanvas.astro +43 -0
  18. package/dist/astro-infinite-canvas/index.ts +1 -0
  19. package/dist/astro-live-captions/LiveCaptions.astro +54 -0
  20. package/dist/astro-live-captions/index.ts +1 -0
  21. package/dist/astro-live-cursors/LiveCursors.astro +55 -0
  22. package/dist/astro-live-cursors/index.ts +1 -0
  23. package/dist/astro-live-transcript/LiveTranscript.astro +55 -0
  24. package/dist/astro-live-transcript/index.ts +1 -0
  25. package/dist/astro-mini-map/MiniMap.astro +74 -0
  26. package/dist/astro-mini-map/index.ts +1 -0
  27. package/dist/astro-pre-call-lobby/PreCallLobby.astro +202 -0
  28. package/dist/astro-pre-call-lobby/index.ts +1 -0
  29. package/dist/astro-radial-gauge/RadialGauge.astro +144 -0
  30. package/dist/astro-radial-gauge/index.ts +1 -0
  31. package/dist/astro-rating-scale/RatingScale.astro +73 -0
  32. package/dist/astro-rating-scale/index.ts +1 -0
  33. package/dist/astro-sticky-note/StickyNote.astro +55 -0
  34. package/dist/astro-sticky-note/index.ts +1 -0
  35. package/dist/astro-terminal/Terminal.astro +67 -0
  36. package/dist/astro-terminal/index.ts +1 -0
  37. package/dist/astro-test-results/TestResults.astro +76 -0
  38. package/dist/astro-test-results/index.ts +1 -0
  39. package/dist/astro-timeline/Timeline.astro +59 -0
  40. package/dist/astro-timeline/index.ts +1 -0
  41. package/dist/astro-video-grid/VideoGrid.astro +182 -0
  42. package/dist/astro-video-grid/index.ts +1 -0
  43. package/dist/astro-video-tile/VideoTile.astro +104 -0
  44. package/dist/astro-video-tile/index.ts +1 -0
  45. package/dist/astro-wizard/Wizard.astro +137 -0
  46. package/dist/astro-wizard/index.ts +1 -0
  47. package/dist/audio-room/audio-room.styles.ts +88 -0
  48. package/dist/audio-room/audio-room.ts +70 -0
  49. package/dist/audio-room/index.ts +16 -0
  50. package/dist/call-controls/call-controls.styles.ts +63 -0
  51. package/dist/call-controls/call-controls.ts +79 -0
  52. package/dist/call-controls/index.ts +15 -0
  53. package/dist/checklist/checklist.styles.ts +76 -0
  54. package/dist/checklist/checklist.ts +62 -0
  55. package/dist/checklist/index.ts +18 -0
  56. package/dist/editor-status-bar/editor-status-bar.styles.ts +40 -0
  57. package/dist/editor-status-bar/editor-status-bar.ts +78 -0
  58. package/dist/editor-status-bar/index.ts +15 -0
  59. package/dist/editor-tabs/editor-tabs.styles.ts +52 -0
  60. package/dist/editor-tabs/editor-tabs.ts +82 -0
  61. package/dist/editor-tabs/index.ts +15 -0
  62. package/dist/floating-reactions/floating-reactions.styles.ts +54 -0
  63. package/dist/floating-reactions/floating-reactions.ts +85 -0
  64. package/dist/floating-reactions/index.ts +14 -0
  65. package/dist/flow-editor/flow-editor.styles.ts +56 -0
  66. package/dist/flow-editor/flow-editor.ts +126 -0
  67. package/dist/flow-editor/index.ts +18 -0
  68. package/dist/graph-view/graph-view.styles.ts +66 -0
  69. package/dist/graph-view/graph-view.ts +130 -0
  70. package/dist/graph-view/index.ts +21 -0
  71. package/dist/index.ts +23 -0
  72. package/dist/infinite-canvas/index.ts +17 -0
  73. package/dist/infinite-canvas/infinite-canvas.styles.ts +49 -0
  74. package/dist/infinite-canvas/infinite-canvas.ts +97 -0
  75. package/dist/live-captions/index.ts +13 -0
  76. package/dist/live-captions/live-captions.styles.ts +45 -0
  77. package/dist/live-captions/live-captions.ts +57 -0
  78. package/dist/live-cursors/index.ts +14 -0
  79. package/dist/live-cursors/live-cursors.styles.ts +41 -0
  80. package/dist/live-cursors/live-cursors.ts +73 -0
  81. package/dist/live-transcript/index.ts +16 -0
  82. package/dist/live-transcript/live-transcript.styles.ts +48 -0
  83. package/dist/live-transcript/live-transcript.ts +82 -0
  84. package/dist/mini-map/index.ts +16 -0
  85. package/dist/mini-map/mini-map.styles.ts +32 -0
  86. package/dist/mini-map/mini-map.ts +122 -0
  87. package/dist/pre-call-lobby/index.ts +22 -0
  88. package/dist/pre-call-lobby/pre-call-lobby.styles.ts +108 -0
  89. package/dist/pre-call-lobby/pre-call-lobby.ts +61 -0
  90. package/dist/radial-gauge/index.ts +26 -0
  91. package/dist/radial-gauge/radial-gauge.styles.ts +75 -0
  92. package/dist/radial-gauge/radial-gauge.ts +183 -0
  93. package/dist/rating-scale/index.ts +16 -0
  94. package/dist/rating-scale/rating-scale.styles.ts +48 -0
  95. package/dist/rating-scale/rating-scale.ts +101 -0
  96. package/dist/sticky-note/index.ts +14 -0
  97. package/dist/sticky-note/sticky-note.styles.ts +37 -0
  98. package/dist/sticky-note/sticky-note.ts +47 -0
  99. package/dist/terminal/index.ts +13 -0
  100. package/dist/terminal/terminal.styles.ts +56 -0
  101. package/dist/terminal/terminal.ts +60 -0
  102. package/dist/test-results/index.ts +19 -0
  103. package/dist/test-results/test-results.styles.ts +93 -0
  104. package/dist/test-results/test-results.ts +83 -0
  105. package/dist/timeline/index.ts +22 -0
  106. package/dist/timeline/timeline.styles.ts +98 -0
  107. package/dist/timeline/timeline.ts +86 -0
  108. package/dist/video-grid/index.ts +16 -0
  109. package/dist/video-grid/video-grid.styles.ts +38 -0
  110. package/dist/video-grid/video-grid.ts +93 -0
  111. package/dist/video-tile/index.ts +17 -0
  112. package/dist/video-tile/video-tile.styles.ts +59 -0
  113. package/dist/video-tile/video-tile.ts +74 -0
  114. package/dist/wizard/index.ts +28 -0
  115. package/dist/wizard/wizard.styles.ts +147 -0
  116. package/dist/wizard/wizard.ts +128 -0
  117. package/package.json +24 -1
@@ -0,0 +1,78 @@
1
+ import type { AccessibilityProps } from '../shared/index.ts'
2
+
3
+ /** Horizontal alignment of a segment within the bar. */
4
+ export type StatusSegmentAlign = 'left' | 'right'
5
+
6
+ /** Visual tone / emphasis of a segment. */
7
+ export type StatusSegmentTone = 'default' | 'muted' | 'accent'
8
+
9
+ /** A single labelled item rendered in the status bar. */
10
+ export interface StatusSegment {
11
+ /** Unique identifier for the segment. */
12
+ id: string
13
+ /** Text displayed in the segment. */
14
+ label: string
15
+ /** Which side of the bar this segment lives on. Defaults to `'left'`. */
16
+ align?: StatusSegmentAlign
17
+ /** Visual tone. Defaults to `'default'`. */
18
+ tone?: StatusSegmentTone
19
+ }
20
+
21
+ /** Return value of {@link createEditorStatusBar}. */
22
+ export interface EditorStatusBarAPI {
23
+ /** ARIA attributes to spread on the bar element. */
24
+ ariaProps: Partial<AccessibilityProps>
25
+ /** Data attributes for styling hooks. */
26
+ dataAttributes: Record<string, string>
27
+ }
28
+
29
+ /**
30
+ * Format a cursor position as the canonical IDE string "Ln {line}, Col {col}".
31
+ *
32
+ * @example
33
+ * formatCursorPosition(17, 1) // → 'Ln 17, Col 1'
34
+ */
35
+ export function formatCursorPosition(line: number, col: number): string {
36
+ return `Ln ${line}, Col ${col}`
37
+ }
38
+
39
+ /**
40
+ * Partition a flat list of segments into left- and right-aligned groups,
41
+ * preserving insertion order within each group.
42
+ */
43
+ export function partitionSegments(segments: StatusSegment[]): {
44
+ left: StatusSegment[]
45
+ right: StatusSegment[]
46
+ } {
47
+ const left: StatusSegment[] = []
48
+ const right: StatusSegment[] = []
49
+ for (const seg of segments) {
50
+ if (seg.align === 'right') {
51
+ right.push(seg)
52
+ } else {
53
+ left.push(seg)
54
+ }
55
+ }
56
+ return { left, right }
57
+ }
58
+
59
+ /**
60
+ * Build the framework-agnostic ARIA and data attributes for the status bar
61
+ * container.
62
+ *
63
+ * The bar uses `role="status"` with `aria-live="polite"` so screen readers
64
+ * announce updates (e.g. cursor position changes) without interrupting the
65
+ * user's current focus.
66
+ */
67
+ export function createEditorStatusBar(): EditorStatusBarAPI {
68
+ const ariaProps: Partial<AccessibilityProps> = {
69
+ role: 'status',
70
+ 'aria-live': 'polite',
71
+ }
72
+
73
+ const dataAttributes: Record<string, string> = {
74
+ 'data-component': 'editor-status-bar',
75
+ }
76
+
77
+ return { ariaProps, dataAttributes }
78
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ formatCursorPosition,
3
+ partitionSegments,
4
+ createEditorStatusBar,
5
+ type StatusSegmentAlign,
6
+ type StatusSegmentTone,
7
+ type StatusSegment,
8
+ type EditorStatusBarAPI,
9
+ } from './editor-status-bar.js'
10
+
11
+ export {
12
+ editorStatusBarVariants,
13
+ statusBarGroupClass,
14
+ statusBarSegmentVariants,
15
+ } from './editor-status-bar.styles.js'
@@ -0,0 +1,52 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /** The tab bar container row. */
4
+ export const editorTabsVariants = cva({
5
+ base: 'inline-flex items-end gap-0 border-b border-border bg-muted/40 overflow-x-auto',
6
+ variants: {},
7
+ defaultVariants: {},
8
+ })
9
+
10
+ /**
11
+ * A single tab button. The `state` variant switches between active and
12
+ * inactive treatments so adapters never inline these classes.
13
+ */
14
+ export const editorTabVariants = cva({
15
+ base: [
16
+ 'group inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium',
17
+ 'border-b-2 transition-colors select-none whitespace-nowrap',
18
+ 'focus-visible:outline-none focus-visible:ring-2',
19
+ 'focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
20
+ ].join(' '),
21
+ variants: {
22
+ state: {
23
+ active: [
24
+ 'border-primary text-foreground bg-background',
25
+ 'border-t border-l border-r border-border border-b-background -mb-px',
26
+ ].join(' '),
27
+ inactive: [
28
+ 'border-transparent text-muted-foreground',
29
+ 'hover:text-foreground hover:bg-background/60',
30
+ ].join(' '),
31
+ },
32
+ },
33
+ defaultVariants: {
34
+ state: 'inactive',
35
+ },
36
+ })
37
+
38
+ /** The dot indicator shown when a tab has unsaved changes. */
39
+ export const editorTabDirtyDotClass =
40
+ 'size-1.5 rounded-full bg-amber-400 shrink-0'
41
+
42
+ /** The close button inside a tab. */
43
+ export const editorTabCloseButtonClass = [
44
+ 'ml-1 inline-flex items-center justify-center rounded-sm size-4 shrink-0',
45
+ 'text-muted-foreground opacity-0 transition-opacity',
46
+ 'group-hover:opacity-100 focus-visible:opacity-100',
47
+ 'hover:bg-accent hover:text-foreground',
48
+ 'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
49
+ ].join(' ')
50
+
51
+ /** The optional icon slot to the left of the label. */
52
+ export const editorTabIconClass = 'shrink-0 text-[0.85em] leading-none'
@@ -0,0 +1,82 @@
1
+ import type { AccessibilityProps } from '../shared/index.ts'
2
+
3
+ /** A single tab entry in the editor tab bar. */
4
+ export interface EditorTabData {
5
+ /** Unique identifier for the tab. */
6
+ id: string
7
+ /** Display label (e.g. "solution.py"). */
8
+ label: string
9
+ /** Optional icon — a short string like an emoji or a ligature glyph. */
10
+ icon?: string
11
+ /** Whether the tab has unsaved changes (shows a dirty dot). */
12
+ dirty?: boolean
13
+ /** Whether the tab shows a close button. */
14
+ closable?: boolean
15
+ }
16
+
17
+ export interface EditorTabsProps {
18
+ /** The id of the currently active tab. */
19
+ activeId?: string
20
+ }
21
+
22
+ export interface EditorTabsAPI {
23
+ /** ARIA attributes to spread on the tab list container. */
24
+ ariaProps: Partial<AccessibilityProps>
25
+ /** Data attributes for styling hooks. */
26
+ dataAttributes: Record<string, string>
27
+ }
28
+
29
+ /**
30
+ * Build the framework-agnostic container props for an IDE-style editor tab bar.
31
+ *
32
+ * Returns `role="tablist"` plus data attributes. Each individual tab is
33
+ * `role="tab"` with `aria-selected`; adapters spread ariaProps on the container
34
+ * and render the tabs themselves.
35
+ */
36
+ export function createEditorTabs(props: EditorTabsProps = {}): EditorTabsAPI {
37
+ const { activeId } = props
38
+
39
+ const ariaProps: Partial<AccessibilityProps> = {
40
+ role: 'tablist',
41
+ }
42
+
43
+ const dataAttributes: Record<string, string> = {}
44
+ if (activeId !== undefined) {
45
+ dataAttributes['data-active-id'] = activeId
46
+ }
47
+
48
+ return { ariaProps, dataAttributes }
49
+ }
50
+
51
+ /**
52
+ * Roving keyboard navigation for the editor tab bar.
53
+ *
54
+ * Unlike a rating scale, tabs **wrap** at the ends so you can cycle through
55
+ * them continuously — matching standard tab-bar behaviour (similar to
56
+ * segmented-control). Home/End jump to the first/last tab.
57
+ *
58
+ * - ArrowRight → next (wraps to first after last)
59
+ * - ArrowLeft → previous (wraps to last before first)
60
+ * - Home → first
61
+ * - End → last
62
+ */
63
+ export function getNextTabIndex(
64
+ currentIndex: number,
65
+ key: string,
66
+ count: number,
67
+ ): number {
68
+ if (count <= 0) return currentIndex
69
+
70
+ switch (key) {
71
+ case 'ArrowRight':
72
+ return (currentIndex + 1) % count
73
+ case 'ArrowLeft':
74
+ return (currentIndex - 1 + count) % count
75
+ case 'Home':
76
+ return 0
77
+ case 'End':
78
+ return count - 1
79
+ default:
80
+ return currentIndex
81
+ }
82
+ }
@@ -0,0 +1,15 @@
1
+ export {
2
+ createEditorTabs,
3
+ getNextTabIndex,
4
+ type EditorTabData,
5
+ type EditorTabsProps,
6
+ type EditorTabsAPI,
7
+ } from './editor-tabs.js'
8
+
9
+ export {
10
+ editorTabsVariants,
11
+ editorTabVariants,
12
+ editorTabDirtyDotClass,
13
+ editorTabCloseButtonClass,
14
+ editorTabIconClass,
15
+ } from './editor-tabs.styles.js'
@@ -0,0 +1,54 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /**
4
+ * Overlay container — fills the parent with absolute positioning so reactions
5
+ * float over content. pointer-events-none keeps the overlay non-interactive;
6
+ * overflow-hidden clips reactions that drift outside the bounds.
7
+ */
8
+ export const floatingReactionsVariants = cva({
9
+ base: 'absolute inset-0 pointer-events-none overflow-hidden',
10
+ variants: {
11
+ /**
12
+ * Whether the overlay is currently active (has reactions). The parent may
13
+ * toggle visibility, but we keep the node mounted to preserve live-region
14
+ * semantics.
15
+ */
16
+ active: {
17
+ true: '',
18
+ false: '',
19
+ },
20
+ },
21
+ defaultVariants: {
22
+ active: 'false',
23
+ },
24
+ })
25
+
26
+ /**
27
+ * A single floating reaction item.
28
+ *
29
+ * The float-up animation is driven by the class
30
+ * `animate-[float-up_3s_ease-out_forwards]`. Consumers must supply the
31
+ * `@keyframes float-up` rule in their global CSS or Tailwind config:
32
+ *
33
+ * ```css
34
+ * @keyframes float-up {
35
+ * 0% { transform: translateY(0); opacity: 1; }
36
+ * 80% { opacity: 1; }
37
+ * 100% { transform: translateY(-80%); opacity: 0; }
38
+ * }
39
+ * ```
40
+ *
41
+ * Without the keyframe the item will still render in place and fade on removal
42
+ * (graceful degradation).
43
+ */
44
+ export const floatingReactionItemClass = [
45
+ 'absolute bottom-4',
46
+ 'text-3xl leading-none select-none',
47
+ 'animate-[float-up_3s_ease-out_forwards]',
48
+ ].join(' ')
49
+
50
+ /**
51
+ * Accessible label node for the reaction — visually hidden so screen readers
52
+ * read the emoji description once and don't repeat the decorative glyph.
53
+ */
54
+ export const floatingReactionLabelClass = 'sr-only'
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Headless core for floating emoji reactions.
3
+ *
4
+ * An emoji burst overlay where reactions animate upward and fade — distinct
5
+ * from the static reaction-bar (which shows counts). This module is JSX-free
6
+ * and framework-agnostic; adapters consume it and manage timing with
7
+ * REACTION_LIFETIME_MS.
8
+ */
9
+
10
+ /** A single transient reaction displayed in the overlay. */
11
+ export interface FloatingReaction {
12
+ /** Stable unique id assigned by the adapter (used as React key / tracker). */
13
+ id: string
14
+ /** The emoji character(s) to display, e.g. '👋' or '❤️'. */
15
+ emoji: string
16
+ /**
17
+ * Horizontal offset bucket (0-indexed). Adapters use laneOffsetPercent() to
18
+ * convert this into a CSS left % so multiple reactions spread across the
19
+ * overlay rather than stacking.
20
+ */
21
+ lane?: number
22
+ }
23
+
24
+ /**
25
+ * How long (ms) a floating reaction should live before the adapter removes it.
26
+ * Use this constant in adapters to drive setTimeout-based auto-expiry so the
27
+ * value stays in sync with the CSS animation duration.
28
+ */
29
+ export const REACTION_LIFETIME_MS = 3000
30
+
31
+ /**
32
+ * Compute the horizontal left-offset percentage for a given lane bucket.
33
+ *
34
+ * Pure and deterministic — safe to call in tests and SSR.
35
+ *
36
+ * @param lane Zero-indexed lane number (clamped to [0, lanes-1]).
37
+ * @param lanes Total number of lanes (default 5).
38
+ * @returns A percentage value in [0, 100].
39
+ *
40
+ * @example
41
+ * laneOffsetPercent(0, 5) // 10
42
+ * laneOffsetPercent(2, 5) // 50
43
+ * laneOffsetPercent(4, 5) // 90
44
+ */
45
+ export function laneOffsetPercent(lane = 0, lanes = 5): number {
46
+ if (lanes <= 0) return 50
47
+ const clamped = Math.max(0, Math.min(lane, lanes - 1))
48
+ // Divide [0,100] into `lanes` equal buckets and return the centre of each.
49
+ const step = 100 / lanes
50
+ return step * clamped + step / 2
51
+ }
52
+
53
+ export interface FloatingReactionsAriaProps {
54
+ role: string
55
+ 'aria-live': 'polite' | 'assertive' | 'off'
56
+ 'aria-label': string
57
+ }
58
+
59
+ export interface FloatingReactionsAPI {
60
+ /** ARIA attributes to spread on the overlay container. */
61
+ ariaProps: FloatingReactionsAriaProps
62
+ /** Data attributes for styling hooks. */
63
+ dataAttributes: Record<string, string>
64
+ }
65
+
66
+ /**
67
+ * Build the framework-agnostic accessibility and data props for the overlay
68
+ * container.
69
+ *
70
+ * The overlay is a `role="status"` live region so screen readers announce
71
+ * incoming reactions without interrupting the user (polite).
72
+ */
73
+ export function createFloatingReactions(): FloatingReactionsAPI {
74
+ const ariaProps: FloatingReactionsAriaProps = {
75
+ role: 'status',
76
+ 'aria-live': 'polite',
77
+ 'aria-label': 'Reactions',
78
+ }
79
+
80
+ const dataAttributes: Record<string, string> = {
81
+ 'data-component': 'floating-reactions',
82
+ }
83
+
84
+ return { ariaProps, dataAttributes }
85
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ createFloatingReactions,
3
+ laneOffsetPercent,
4
+ REACTION_LIFETIME_MS,
5
+ type FloatingReaction,
6
+ type FloatingReactionsAriaProps,
7
+ type FloatingReactionsAPI,
8
+ } from './floating-reactions.js'
9
+
10
+ export {
11
+ floatingReactionsVariants,
12
+ floatingReactionItemClass,
13
+ floatingReactionLabelClass,
14
+ } from './floating-reactions.styles.js'
@@ -0,0 +1,56 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /**
4
+ * Root wrapper for the flow editor canvas. Sized to fill its container and
5
+ * provides a relative positioning context for the node/edge layers.
6
+ */
7
+ export const flowEditorVariants = cva({
8
+ base: 'relative w-full overflow-hidden bg-background border border-border rounded-lg',
9
+ variants: {
10
+ size: {
11
+ sm: 'h-64',
12
+ md: 'h-96',
13
+ lg: 'h-[32rem]',
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ size: 'md',
18
+ },
19
+ })
20
+
21
+ /**
22
+ * A single node box. The `selected` variant adds a ring to signal the active
23
+ * selection; semantic token classes are used throughout so theming works
24
+ * without overrides.
25
+ */
26
+ export const flowNodeVariants = cva({
27
+ base: [
28
+ 'absolute flex items-center justify-center rounded-md border',
29
+ 'bg-card border-border text-card-foreground text-sm font-medium',
30
+ 'cursor-pointer select-none transition-shadow',
31
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
32
+ ].join(' '),
33
+ variants: {
34
+ selected: {
35
+ true: 'ring-2 ring-ring ring-primary shadow-md',
36
+ false: 'hover:border-primary/50 hover:shadow-sm',
37
+ },
38
+ },
39
+ defaultVariants: {
40
+ selected: 'false',
41
+ },
42
+ })
43
+
44
+ /** Accessible label text inside a node box. */
45
+ export const flowNodeLabelClass = 'px-3 py-2 truncate pointer-events-none'
46
+
47
+ /**
48
+ * SVG `<path>` element class for an edge. `currentColor` is used so the
49
+ * stroke inherits from `text-muted-foreground` set on the SVG container.
50
+ */
51
+ export const flowEdgePathClass =
52
+ 'fill-none stroke-current stroke-2 transition-opacity'
53
+
54
+ /** Text label positioned at the midpoint of an edge. */
55
+ export const flowEdgeLabelClass =
56
+ 'text-xs fill-muted-foreground select-none pointer-events-none'
@@ -0,0 +1,126 @@
1
+ /** A node in the flow editor canvas. */
2
+ export type FlowNode = {
3
+ id: string
4
+ x: number
5
+ y: number
6
+ width?: number
7
+ height?: number
8
+ label: string
9
+ selected?: boolean
10
+ }
11
+
12
+ /** A directed edge connecting two nodes. */
13
+ export type FlowEdge = {
14
+ id: string
15
+ source: string
16
+ target: string
17
+ label?: string
18
+ }
19
+
20
+ /** Default node dimensions used when width/height are not specified. */
21
+ const DEFAULT_NODE_WIDTH = 160
22
+ const DEFAULT_NODE_HEIGHT = 48
23
+
24
+ /**
25
+ * Compute the center point of a node in canvas coordinates.
26
+ * Uses the node's explicit dimensions when available, otherwise falls back to
27
+ * the default box size.
28
+ */
29
+ export function nodeCenter(node: FlowNode): { x: number; y: number } {
30
+ const w = node.width ?? DEFAULT_NODE_WIDTH
31
+ const h = node.height ?? DEFAULT_NODE_HEIGHT
32
+ return { x: node.x + w / 2, y: node.y + h / 2 }
33
+ }
34
+
35
+ /**
36
+ * Find a node by id. Returns `undefined` when not found; callers must guard.
37
+ */
38
+ export function nodeById(
39
+ nodes: FlowNode[],
40
+ id: string,
41
+ ): FlowNode | undefined {
42
+ return nodes.find((n) => n.id === id)
43
+ }
44
+
45
+ /**
46
+ * Produce an SVG cubic-bezier path `d` string between the anchor points of two
47
+ * nodes. The control handles extend horizontally from each anchor so the curve
48
+ * is smooth regardless of relative position — matching the easyloops
49
+ * architecture-editor style.
50
+ *
51
+ * Source anchor: right-center of the source node.
52
+ * Target anchor: left-center of the target node.
53
+ */
54
+ export function edgePath(source: FlowNode, target: FlowNode): string {
55
+ const sw = source.width ?? DEFAULT_NODE_WIDTH
56
+ const sh = source.height ?? DEFAULT_NODE_HEIGHT
57
+ const th = target.height ?? DEFAULT_NODE_HEIGHT
58
+
59
+ const x1 = source.x + sw
60
+ const y1 = source.y + sh / 2
61
+ const x2 = target.x
62
+ const y2 = target.y + th / 2
63
+
64
+ const dx = Math.abs(x2 - x1)
65
+ const handleLen = Math.max(dx * 0.5, 40)
66
+
67
+ const cx1 = x1 + handleLen
68
+ const cy1 = y1
69
+ const cx2 = x2 - handleLen
70
+ const cy2 = y2
71
+
72
+ return `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`
73
+ }
74
+
75
+ /**
76
+ * Compute the bounding box that contains all nodes. Useful for fitting the
77
+ * viewport to content. Returns zeros when the node list is empty.
78
+ */
79
+ export function contentBounds(nodes: FlowNode[]): {
80
+ minX: number
81
+ minY: number
82
+ maxX: number
83
+ maxY: number
84
+ } {
85
+ if (nodes.length === 0) {
86
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0 }
87
+ }
88
+
89
+ let minX = Infinity
90
+ let minY = Infinity
91
+ let maxX = -Infinity
92
+ let maxY = -Infinity
93
+
94
+ for (const node of nodes) {
95
+ const w = node.width ?? DEFAULT_NODE_WIDTH
96
+ const h = node.height ?? DEFAULT_NODE_HEIGHT
97
+ if (node.x < minX) minX = node.x
98
+ if (node.y < minY) minY = node.y
99
+ if (node.x + w > maxX) maxX = node.x + w
100
+ if (node.y + h > maxY) maxY = node.y + h
101
+ }
102
+
103
+ return { minX, minY, maxX, maxY }
104
+ }
105
+
106
+ /** Accessibility and data attributes for the root flow editor container. */
107
+ export interface FlowEditorAPI {
108
+ ariaProps: Record<string, string | number | boolean>
109
+ dataAttributes: Record<string, string>
110
+ }
111
+
112
+ /**
113
+ * Return the framework-agnostic root attributes for a FlowEditor.
114
+ * Adapters spread `ariaProps` and `dataAttributes` on the outermost element.
115
+ */
116
+ export function createFlowEditor(): FlowEditorAPI {
117
+ return {
118
+ ariaProps: {
119
+ role: 'group',
120
+ 'aria-label': 'Flow editor',
121
+ },
122
+ dataAttributes: {
123
+ 'data-flow-editor': 'true',
124
+ },
125
+ }
126
+ }
@@ -0,0 +1,18 @@
1
+ export {
2
+ nodeCenter,
3
+ nodeById,
4
+ edgePath,
5
+ contentBounds,
6
+ createFlowEditor,
7
+ type FlowNode,
8
+ type FlowEdge,
9
+ type FlowEditorAPI,
10
+ } from './flow-editor.js'
11
+
12
+ export {
13
+ flowEditorVariants,
14
+ flowNodeVariants,
15
+ flowNodeLabelClass,
16
+ flowEdgePathClass,
17
+ flowEdgeLabelClass,
18
+ } from './flow-editor.styles.js'
@@ -0,0 +1,66 @@
1
+ import { cva } from '../shared/index.ts'
2
+
3
+ /** Outer wrapper for the graph canvas area. */
4
+ export const graphViewVariants = cva({
5
+ base: 'relative w-full overflow-hidden rounded-lg border border-border bg-background',
6
+ variants: {},
7
+ defaultVariants: {},
8
+ })
9
+
10
+ /**
11
+ * Node chip: a positioned, clickable concept node colored by mastery state.
12
+ *
13
+ * `state` variant drives the visual treatment:
14
+ * - mastered → filled primary (bg-primary / text-primary-foreground)
15
+ * - in-progress → accent ring (ring-2 ring-ring bg-accent text-accent-foreground)
16
+ * - not-started → muted (bg-muted text-muted-foreground)
17
+ * - highlight → border-primary accent outline (border-primary bg-background text-foreground)
18
+ */
19
+ export const graphNodeVariants = cva({
20
+ base: [
21
+ 'absolute inline-flex items-center justify-center rounded-md border px-2 py-1',
22
+ 'text-xs font-medium leading-none -translate-x-1/2 -translate-y-1/2',
23
+ 'transition-colors focus-visible:outline-none focus-visible:ring-2',
24
+ 'focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
25
+ 'cursor-default select-none',
26
+ ].join(' '),
27
+ variants: {
28
+ state: {
29
+ mastered: 'border-transparent bg-primary text-primary-foreground',
30
+ 'in-progress': 'border-transparent bg-accent text-accent-foreground ring-2 ring-ring',
31
+ 'not-started': 'border-transparent bg-muted text-muted-foreground',
32
+ highlight: 'border-primary bg-background text-foreground',
33
+ },
34
+ interactive: {
35
+ true: 'cursor-pointer hover:opacity-90',
36
+ false: 'cursor-default',
37
+ },
38
+ },
39
+ defaultVariants: {
40
+ state: 'not-started',
41
+ interactive: 'false',
42
+ },
43
+ })
44
+
45
+ /** Label text rendered inside each node chip (already embedded in the chip). */
46
+ export const graphNodeLabelClass = 'pointer-events-none whitespace-nowrap'
47
+
48
+ /** SVG edge line/path classes. */
49
+ export const graphEdgeClass =
50
+ 'fill-none stroke-border stroke-[1.5] opacity-60'
51
+
52
+ /** Legend chip: a small colored indicator + label for a single mastery state. */
53
+ export const graphLegendChipVariants = cva({
54
+ base: 'inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium',
55
+ variants: {
56
+ state: {
57
+ mastered: 'border-transparent bg-primary text-primary-foreground',
58
+ 'in-progress': 'border-transparent bg-accent text-accent-foreground ring-1 ring-ring',
59
+ 'not-started': 'border-transparent bg-muted text-muted-foreground',
60
+ highlight: 'border-primary bg-background text-foreground',
61
+ },
62
+ },
63
+ defaultVariants: {
64
+ state: 'not-started',
65
+ },
66
+ })