@human-synthesis/norns-ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-ui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "UI library for the Norns ecosystem — Pug + Civet components on Tailwind v4.",
5
5
  "license": "MIT",
6
6
  "author": "Daniel Teodoroiu (https://humansynthesis.ai)",
@@ -15,6 +15,7 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "test": "bun test",
18
+ "build:manifest": "bun scripts/build-manifest.mjs",
18
19
  "check:shims": "bun scripts/check-shims.mjs",
19
20
  "check:shims:strict": "bun scripts/check-shims.mjs --strict"
20
21
  },
@@ -30,12 +31,13 @@
30
31
  "./styles/atoms": "./src/styles/atoms.css",
31
32
  "./components/*": "./src/components/*",
32
33
  "./contracts": "./src/contracts.js",
34
+ "./manifest": "./src/palette-manifest.json",
33
35
  "./motion": "./src/motion/index.js",
34
36
  "./motion/*": "./src/motion/*",
35
37
  "./package.json": "./package.json"
36
38
  },
37
39
  "peerDependencies": {
38
- "@human-synthesis/norns": "^0.1.0",
40
+ "@human-synthesis/norns": "^0.2.0",
39
41
  "@human-synthesis/norns-core": "^0.0.10",
40
42
  "svelte": "^5.0.0",
41
43
  "tailwindcss": "^4.0.0"
@@ -123,7 +123,14 @@ export function presetUI() {
123
123
  CopyButton: c('CopyButton'),
124
124
  ThemeToggler: c('ThemeToggler'),
125
125
  ShinyButton: c('ShinyButton'),
126
- RippleButton: c('RippleButton')
126
+ RippleButton: c('RippleButton'),
127
+
128
+ // U-09 — realtime tier
129
+ StreamText: c('StreamText'),
130
+ ChatThread: c('ChatThread'),
131
+ Feed: c('Feed'),
132
+ PresenceAvatars: c('PresenceAvatars'),
133
+ LiveLog: c('LiveLog')
127
134
  // Motion-driven components live in `@human-synthesis/norns-ui/motion`
128
135
  // and are NOT registered here (opt-in import only).
129
136
  },
@@ -0,0 +1,97 @@
1
+ .chat-thread(class!="{classes}")
2
+ ul.chat-thread-list
3
+ +if('messages.length === 0')
4
+ li.chat-thread-empty {emptyMessage}
5
+ +each('messages as msg')
6
+ li.chat-thread-msg(class!="{msg.self ? 'chat-thread-msg-self' : ''}")
7
+ +if('message')
8
+ | {@render message(msg)}
9
+ +if('!message')
10
+ +if('msg.from')
11
+ span.chat-thread-from {msg.from}
12
+ span.chat-thread-text {msg.text}
13
+ form.chat-thread-composer(onsubmit!="{submit}")
14
+ input.input.chat-thread-input(bind:value!="{draft}" placeholder!="{placeholder}")
15
+ button.btn.btn-primary(type="submit" disabled!="{draft.trim() === ''}") {sendLabel}
16
+
17
+ <script>
18
+ import { cn } from '@human-synthesis/norns-ui/cn'
19
+ import { roomChannel as openRoom } from '@human-synthesis/norns/live-client'
20
+
21
+ {
22
+ roomChannel = undefined
23
+ send = 'chat'
24
+ receive = undefined
25
+ placeholder = 'Message'
26
+ sendLabel = 'Send'
27
+ emptyMessage = 'No messages yet'
28
+ message = undefined
29
+ class: extra = ''
30
+ } .= $props()
31
+
32
+ messages .= $state([])
33
+ draft .= $state('')
34
+ channel .= $state(undefined)
35
+
36
+ classes := $derived cn('chat-thread', extra)
37
+
38
+ $effect =>
39
+ return unless roomChannel
40
+ ch := openRoom(roomChannel)
41
+ channel = ch
42
+ off := ch.on(receive ?? send, (frame) => messages = [...messages, frame])
43
+ =>
44
+ off()
45
+ ch.close()
46
+ channel = undefined
47
+
48
+ submit := (e) =>
49
+ e.preventDefault()
50
+ body := draft.trim()
51
+ return unless body and channel
52
+ channel.send(send, { text: body })
53
+ messages = [...messages, { text: body, self: true }]
54
+ draft = ''
55
+ </script>
56
+
57
+ <style>
58
+ .chat-thread {
59
+ display: grid;
60
+ gap: var(--spacing-2, 0.5rem);
61
+ }
62
+ .chat-thread-list {
63
+ display: grid;
64
+ gap: var(--spacing-1, 0.25rem);
65
+ max-height: 24rem;
66
+ overflow-y: auto;
67
+ list-style: none;
68
+ margin: 0;
69
+ padding: 0;
70
+ }
71
+ .chat-thread-empty {
72
+ opacity: 0.6;
73
+ font-size: 0.875rem;
74
+ }
75
+ .chat-thread-msg {
76
+ display: flex;
77
+ gap: var(--spacing-2, 0.5rem);
78
+ align-items: baseline;
79
+ padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
80
+ border-radius: var(--radius-md, 0.375rem);
81
+ background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
82
+ }
83
+ .chat-thread-msg-self {
84
+ background: color-mix(in oklab, var(--color-primary-500, currentColor) 12%, transparent);
85
+ }
86
+ .chat-thread-from {
87
+ font-weight: 600;
88
+ font-size: 0.8125rem;
89
+ }
90
+ .chat-thread-composer {
91
+ display: flex;
92
+ gap: var(--spacing-2, 0.5rem);
93
+ }
94
+ .chat-thread-input {
95
+ flex: 1;
96
+ }
97
+ </style>
@@ -17,12 +17,18 @@
17
17
  +if('rows.length === 0')
18
18
  tr.data-table-empty
19
19
  td(colspan!="{columns.length}")
20
- | {emptyMessage}
20
+ +if('empty')
21
+ | {@render empty()}
22
+ +if('!empty')
23
+ | {emptyMessage}
21
24
  +each('rows as row, i')
22
25
  tr(onclick!="{onrowclick ? () => onrowclick(row, i) : undefined}" class!="{onrowclick ? 'data-table-row-clickable' : ''}")
23
26
  +each('columns as col')
24
27
  td(class!="{col.cellClass ?? ''}")
25
- | {row[col.key]}
28
+ +if('cell')
29
+ | {@render cell(row, col.key, row[col.key])}
30
+ +if('!cell')
31
+ | {row[col.key]}
26
32
 
27
33
  <script>
28
34
  import { cn } from '@human-synthesis/norns-ui/cn'
@@ -38,6 +44,8 @@
38
44
  sortKey = $bindable(undefined)
39
45
  sortDir = $bindable('asc')
40
46
  onrowclick
47
+ cell = undefined
48
+ empty = undefined
41
49
  class: extra = ''
42
50
  } .= $props()
43
51
 
@@ -0,0 +1,71 @@
1
+ ul.feed(class!="{classes}" aria-live="polite")
2
+ +if('entries.length === 0')
3
+ li.feed-empty {emptyMessage}
4
+ +each('entries as it')
5
+ li.feed-item
6
+ +if('item')
7
+ | {@render item(it)}
8
+ +if('!item')
9
+ | {typeof it === 'string' ? it : (it.text ?? JSON.stringify(it))}
10
+
11
+ <script>
12
+ import { cn } from '@human-synthesis/norns-ui/cn'
13
+ import { streamSource as openStream, roomChannel as openRoom } from '@human-synthesis/norns/live-client'
14
+
15
+ {
16
+ streamSource = undefined
17
+ roomChannel = undefined
18
+ receive = '*'
19
+ max = 100
20
+ emptyMessage = 'Nothing yet'
21
+ item = undefined
22
+ class: extra = ''
23
+ } .= $props()
24
+
25
+ entries .= $state([])
26
+
27
+ classes := $derived cn('feed', extra)
28
+
29
+ append := (frame) =>
30
+ entries = [...entries, frame].slice(-Math.max(1, Number(max) || 100))
31
+
32
+ $effect =>
33
+ return unless roomChannel
34
+ ch := openRoom(roomChannel)
35
+ off := ch.on(receive, append)
36
+ =>
37
+ off()
38
+ ch.close()
39
+
40
+ $effect =>
41
+ return unless streamSource
42
+ let stopped = false
43
+ (async =>
44
+ try
45
+ for await (const frame of openStream(streamSource))
46
+ return if stopped
47
+ append(frame)
48
+ catch
49
+ return
50
+ )()
51
+ => stopped = true
52
+ </script>
53
+
54
+ <style>
55
+ .feed {
56
+ display: grid;
57
+ gap: var(--spacing-1, 0.25rem);
58
+ list-style: none;
59
+ margin: 0;
60
+ padding: 0;
61
+ }
62
+ .feed-empty {
63
+ opacity: 0.6;
64
+ font-size: 0.875rem;
65
+ }
66
+ .feed-item {
67
+ padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
68
+ border-radius: var(--radius-md, 0.375rem);
69
+ background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
70
+ }
71
+ </style>
@@ -11,7 +11,11 @@
11
11
  span.kanban-column-count {col.cards.length}
12
12
  .kanban-cards
13
13
  +if('col.cards.length === 0')
14
- .kanban-empty {emptyMessage}
14
+ .kanban-empty
15
+ +if('empty')
16
+ | {@render empty()}
17
+ +if('!empty')
18
+ | {emptyMessage}
15
19
  +each('col.cards as card, i (cardId(card) ?? i)')
16
20
  article.kanban-card(
17
21
  draggable!="{movable}"
@@ -19,12 +23,16 @@
19
23
  onclick!="{oncardclick ? () => oncardclick(card) : undefined}"
20
24
  class!="{oncardclick ? 'kanban-card-clickable' : ''}"
21
25
  )
22
- .kanban-card-title {titleOf(card)}
23
- +if('subtitle && card[subtitle] != null')
24
- .kanban-card-subtitle {card[subtitle]}
26
+ +if('renderCard')
27
+ | {@render renderCard(card)}
28
+ +if('!renderCard')
29
+ .kanban-card-title {titleOf(card)}
30
+ +if('subtitle && card[subtitle] != null')
31
+ .kanban-card-subtitle {card[subtitle]}
25
32
 
26
33
  <script>
27
34
  import { cn } from '@human-synthesis/norns-ui/cn'
35
+ import { optimisticOverlay } from '@human-synthesis/norns-ui/behaviors/optimistic.svelte.js'
28
36
 
29
37
  {
30
38
  data = {}
@@ -34,11 +42,13 @@
34
42
  onMove = undefined
35
43
  oncardclick = undefined
36
44
  emptyMessage = 'No cards'
45
+ card: renderCard = undefined
46
+ empty = undefined
37
47
  class: extra = ''
38
48
  } .= $props()
39
49
 
40
50
  // local overlay so a drop lands instantly; server truth wins on reload
41
- moves .= $state({})
51
+ moves := optimisticOverlay()
42
52
 
43
53
  movable := $derived onMove !== undefined
44
54
  rootClasses := $derived cn('kanban', extra)
@@ -70,7 +80,7 @@
70
80
  const byKey = new Map(out.map((c) => [c.key, c]))
71
81
  for (const [key, cards] of Object.entries(grouped))
72
82
  for (const card of Array.isArray(cards) ? cards : [])
73
- const to = moves[cardId(card)] ?? key
83
+ const to = moves.get(cardId(card), key)
74
84
  byKey.get(to)?.cards.push(card)
75
85
  out
76
86
  }
@@ -109,7 +119,7 @@
109
119
  { card, from } := dragged
110
120
  dragged = undefined
111
121
  return if from === to
112
- moves = { ...moves, [cardId(card)]: to }
122
+ moves.set(cardId(card), to)
113
123
  submitMove(card, to)
114
124
  </script>
115
125
 
@@ -0,0 +1,64 @@
1
+ pre.live-log(class!="{classes}" bind:this!="{el}" aria-live="polite")
2
+ | {lines.join('\n')}
3
+
4
+ <script>
5
+ import { cn } from '@human-synthesis/norns-ui/cn'
6
+ import { streamSource as openStream, roomChannel as openRoom } from '@human-synthesis/norns/live-client'
7
+
8
+ {
9
+ streamSource = undefined
10
+ roomChannel = undefined
11
+ receive = '*'
12
+ maxLines = 200
13
+ class: extra = ''
14
+ } .= $props()
15
+
16
+ lines .= $state([])
17
+ el .= $state(undefined)
18
+
19
+ classes := $derived cn('live-log', extra)
20
+
21
+ append := (frame) =>
22
+ line := typeof frame === 'string' ? frame : JSON.stringify(frame)
23
+ lines = [...lines, line].slice(-Math.max(1, Number(maxLines) || 200))
24
+
25
+ $effect =>
26
+ return unless roomChannel
27
+ ch := openRoom(roomChannel)
28
+ off := ch.on(receive, append)
29
+ =>
30
+ off()
31
+ ch.close()
32
+
33
+ $effect =>
34
+ return unless streamSource
35
+ let stopped = false
36
+ (async =>
37
+ try
38
+ for await (const frame of openStream(streamSource))
39
+ return if stopped
40
+ append(frame)
41
+ catch
42
+ return
43
+ )()
44
+ => stopped = true
45
+
46
+ $effect =>
47
+ lines
48
+ el?.scrollTo?.(0, el.scrollHeight)
49
+ return
50
+ </script>
51
+
52
+ <style>
53
+ .live-log {
54
+ font-family: var(--font-mono, ui-monospace, monospace);
55
+ font-size: 0.8125rem;
56
+ line-height: 1.5;
57
+ max-height: 20rem;
58
+ overflow-y: auto;
59
+ margin: 0;
60
+ padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
61
+ border-radius: var(--radius-md, 0.375rem);
62
+ background: var(--color-surface-2, color-mix(in oklab, currentColor 5%, transparent));
63
+ }
64
+ </style>
@@ -0,0 +1,45 @@
1
+ .presence-avatars(class!="{classes}" role="status" aria-label!="{`${clients} ${label}`}")
2
+ AvatarGroup(items!="{people}" max!="{max}" size!="{size}" label!="{label}")
3
+ +if('showCount')
4
+ span.presence-avatars-count {clients} {label}
5
+
6
+ <script>
7
+ import { cn } from '@human-synthesis/norns-ui/cn'
8
+ import { roomChannel as openRoom } from '@human-synthesis/norns/live-client'
9
+ import AvatarGroup from './AvatarGroup.n'
10
+
11
+ {
12
+ roomChannel = undefined
13
+ max = 5
14
+ size = 'sm'
15
+ label = 'online'
16
+ showCount = true
17
+ class: extra = ''
18
+ } .= $props()
19
+
20
+ clients .= $state(0)
21
+
22
+ classes := $derived cn('presence-avatars', extra)
23
+ people := $derived Array.from({ length: Math.min(clients, Number(max) || 5) }, (_, i) => ({ name: `Guest ${i + 1}` }))
24
+
25
+ $effect =>
26
+ return unless roomChannel
27
+ ch := openRoom(roomChannel)
28
+ off := ch.on('presence', (frame) => clients = Number(frame?.clients) || 0)
29
+ =>
30
+ off()
31
+ ch.close()
32
+ </script>
33
+
34
+ <style>
35
+ .presence-avatars {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: var(--spacing-2, 0.5rem);
39
+ }
40
+ .presence-avatars-count {
41
+ font-size: 0.8125rem;
42
+ opacity: 0.7;
43
+ font-variant-numeric: tabular-nums;
44
+ }
45
+ </style>
@@ -0,0 +1,64 @@
1
+ .stream-text(class!="{classes}" aria-live="polite" aria-busy!="{streaming}")
2
+ | {text}
3
+ +if('streaming')
4
+ span.stream-text-cursor(aria-hidden="true")
5
+
6
+ <script>
7
+ import { cn } from '@human-synthesis/norns-ui/cn'
8
+ import { streamSource as openStream } from '@human-synthesis/norns/live-client'
9
+
10
+ {
11
+ streamSource = undefined
12
+ input = undefined
13
+ autostart = true
14
+ onframe = undefined
15
+ ondone = undefined
16
+ class: extra = ''
17
+ } .= $props()
18
+
19
+ text .= $state('')
20
+ streaming .= $state(false)
21
+
22
+ classes := $derived cn('stream-text', extra)
23
+
24
+ start := async (override) =>
25
+ return unless streamSource
26
+ return if streaming
27
+ text = ''
28
+ streaming = true
29
+ body := override ?? input
30
+ try
31
+ opts := body === undefined ? {} : { input: body }
32
+ for await (const frame of openStream(streamSource, opts))
33
+ delta := typeof frame === 'string' ? frame : (frame?.delta ?? '')
34
+ text += delta
35
+ onframe?.(frame)
36
+ finally
37
+ streaming = false
38
+ ondone?.(text)
39
+
40
+ $effect =>
41
+ start() if autostart
42
+ return
43
+ </script>
44
+
45
+ <style>
46
+ .stream-text {
47
+ white-space: pre-wrap;
48
+ overflow-wrap: anywhere;
49
+ }
50
+ .stream-text-cursor {
51
+ display: inline-block;
52
+ width: 0.55ch;
53
+ height: 1em;
54
+ margin-left: 0.15ch;
55
+ vertical-align: text-bottom;
56
+ background: currentColor;
57
+ animation: stream-text-blink 1s steps(2, start) infinite;
58
+ }
59
+ @keyframes stream-text-blink {
60
+ to {
61
+ visibility: hidden;
62
+ }
63
+ }
64
+ </style>
@@ -7,6 +7,8 @@
7
7
  stickyHeader!="{stickyHeader}"
8
8
  emptyMessage!="{emptyMessage}"
9
9
  onrowclick!="{onrowclick}"
10
+ cell!="{cell}"
11
+ empty!="{empty}"
10
12
  bind:sortKey
11
13
  bind:sortDir
12
14
  )
@@ -29,6 +31,8 @@
29
31
  stickyHeader = false
30
32
  emptyMessage = 'No data'
31
33
  onrowclick
34
+ cell = undefined
35
+ empty = undefined
32
36
  class: extra = ''
33
37
  } .= $props()
34
38
 
@@ -1,10 +1,10 @@
1
1
  ol(class!="{classes}")
2
- +each('items as item, i')
2
+ +each('list as item, i')
3
3
  li.timeline-item
4
4
  .timeline-dot(class!="{item.variant ? `timeline-dot-${item.variant}` : ''}")
5
5
  +if('item.icon')
6
6
  Icon(name!="{item.icon}" size="size-3")
7
- +if('i < items.length - 1')
7
+ +if('i < list.length - 1')
8
8
  .timeline-line
9
9
  .timeline-body
10
10
  +if('item.time')
@@ -19,9 +19,11 @@ ol(class!="{classes}")
19
19
  import Icon from './Icon.n'
20
20
 
21
21
  {
22
- items = []
22
+ items = undefined
23
+ data = undefined
23
24
  class: extra = ''
24
25
  } := $props()
25
26
 
27
+ list := items ?? data ?? []
26
28
  classes := cn 'timeline', extra
27
29
  </script>
@@ -1,5 +1,5 @@
1
1
  ul.tree(class!="{classes}" role="tree")
2
- +each('items as node')
2
+ +each('list as node')
3
3
  | {@render renderNode(node, 0)}
4
4
 
5
5
  +snippet('renderNode', node, depth)
@@ -33,12 +33,14 @@ ul.tree(class!="{classes}" role="tree")
33
33
  import Icon from './Icon.n'
34
34
 
35
35
  {
36
- items = []
36
+ items = undefined
37
+ data = undefined
37
38
  expanded = $bindable([])
38
39
  selected = $bindable(undefined)
39
40
  class: extra = ''
40
41
  } .= $props()
41
42
 
43
+ list := items ?? data ?? []
42
44
  classes := cn 'tree', extra
43
45
 
44
46
  hasChildren := (node) => Array.isArray(node.children) && node.children.length > 0