@human-synthesis/norns-ui 0.0.11 → 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.0.11",
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
  },
@@ -29,12 +30,14 @@
29
30
  "./styles/tokens": "./src/styles/tokens.css",
30
31
  "./styles/atoms": "./src/styles/atoms.css",
31
32
  "./components/*": "./src/components/*",
33
+ "./contracts": "./src/contracts.js",
34
+ "./manifest": "./src/palette-manifest.json",
32
35
  "./motion": "./src/motion/index.js",
33
36
  "./motion/*": "./src/motion/*",
34
37
  "./package.json": "./package.json"
35
38
  },
36
39
  "peerDependencies": {
37
- "@human-synthesis/norns": "^0.0.16",
40
+ "@human-synthesis/norns": "^0.2.0",
38
41
  "@human-synthesis/norns-core": "^0.0.10",
39
42
  "svelte": "^5.0.0",
40
43
  "tailwindcss": "^4.0.0"
@@ -49,6 +52,7 @@
49
52
  },
50
53
  "dependencies": {
51
54
  "@floating-ui/dom": "^1.7.6",
55
+ "valibot": "^1.4.0",
52
56
  "@fontsource-variable/outfit": "^5.2.8",
53
57
  "@iconify-json/lucide": "^1.2.105",
54
58
  "@iconify/svelte": "^5.2.1",
@@ -63,6 +63,7 @@ export function presetUI() {
63
63
  Avatar: c('Avatar'),
64
64
  Skeleton: c('Skeleton'),
65
65
  Progress: c('Progress'),
66
+ Spinner: c('Spinner'),
66
67
  ProgressCircular: c('ProgressCircular'),
67
68
 
68
69
  // 0.0.4 — composite layer
@@ -106,6 +107,15 @@ export function presetUI() {
106
107
  TimePicker: c('TimePicker'),
107
108
  Calendar: c('Calendar'),
108
109
  DataTable: c('DataTable'),
110
+ Table: c('Table'),
111
+
112
+ // U-03 — data views
113
+ Kanban: c('Kanban'),
114
+ Chart: c('Chart'),
115
+
116
+ // U-04 — data tier (Combobox is Autocomplete's ARIA name; same component)
117
+ Listbox: c('Listbox'),
118
+ Combobox: c('Autocomplete'),
109
119
 
110
120
  // 0.0.5 — CSS-only motion components
111
121
  AvatarGroup: c('AvatarGroup'),
@@ -113,7 +123,14 @@ export function presetUI() {
113
123
  CopyButton: c('CopyButton'),
114
124
  ThemeToggler: c('ThemeToggler'),
115
125
  ShinyButton: c('ShinyButton'),
116
- 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')
117
134
  // Motion-driven components live in `@human-synthesis/norns-ui/motion`
118
135
  // and are NOT registered here (opt-in import only).
119
136
  },
@@ -0,0 +1,129 @@
1
+ figure.chart-root(class!="{rootClasses}")
2
+ svg(viewBox!="{`0 0 ${W} ${H}`}" role="img" aria-label!="{label}" preserveAspectRatio="none")
3
+ line.chart-axis(x1!="{pad}" y1!="{H - pad}" x2!="{W - pad}" y2!="{H - pad}")
4
+ +if('type === "bar"')
5
+ +each('points as p (p.i)')
6
+ rect.chart-bar(
7
+ x!="{p.x - barW / 2}"
8
+ y!="{p.y}"
9
+ width!="{barW}"
10
+ height!="{H - pad - p.y}"
11
+ )
12
+ +if('type === "line" || type === "area"')
13
+ +if('type === "area"')
14
+ path.chart-area(d!="{areaPath}")
15
+ path.chart-line(d!="{linePath}")
16
+ +each('points as p (p.i)')
17
+ circle.chart-dot(cx!="{p.x}" cy!="{p.y}" r="2.5")
18
+ +if('showLabels')
19
+ .chart-labels
20
+ +each('points as p (p.i)')
21
+ span.chart-label {p.label}
22
+
23
+ <script>
24
+ import { cn } from '@human-synthesis/norns-ui/cn'
25
+
26
+ {
27
+ data = []
28
+ type = 'bar'
29
+ x = undefined
30
+ y = undefined
31
+ label = 'chart'
32
+ labels = true
33
+ class: extra = ''
34
+ } .= $props()
35
+
36
+ W := 600
37
+ H := 200
38
+ pad := 8
39
+
40
+ rootClasses := $derived cn('chart', `chart-${type}`, extra)
41
+
42
+ rows := $derived Array.isArray(data) ? data : []
43
+
44
+ xKey := $derived.by => {
45
+ return x if x
46
+ const first = rows[0] ?? {}
47
+ Object.keys(first).find((k) => typeof first[k] !== 'number') ?? Object.keys(first)[0]
48
+ }
49
+
50
+ yKey := $derived.by => {
51
+ return y if y
52
+ const first = rows[0] ?? {}
53
+ Object.keys(first).find((k) => typeof first[k] === 'number' && k !== xKey)
54
+ }
55
+
56
+ values := $derived rows.map((row) => Number(row?.[yKey]) || 0)
57
+ maxV := $derived Math.max(1, ...values)
58
+
59
+ points := $derived.by => {
60
+ const n = rows.length
61
+ return [] if n === 0
62
+ const span = W - pad * 2
63
+ const step = n > 1 ? span / (n - 1) : 0
64
+ rows.map (row, i) =>
65
+ const v = Number(row?.[yKey]) || 0
66
+ {
67
+ i
68
+ x: n > 1 ? pad + step * i : W / 2
69
+ y: H - pad - (v / maxV) * (H - pad * 2)
70
+ v
71
+ label: String(row?.[xKey] ?? i + 1)
72
+ }
73
+ }
74
+
75
+ barW := $derived.by => {
76
+ const n = Math.max(1, points.length)
77
+ Math.max(4, Math.min(48, ((W - pad * 2) / n) * 0.6))
78
+ }
79
+
80
+ linePath := $derived points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
81
+
82
+ areaPath := $derived.by => {
83
+ return '' if points.length === 0
84
+ const first = points[0]
85
+ const last = points[points.length - 1]
86
+ `${linePath} L${last.x},${H - pad} L${first.x},${H - pad} Z`
87
+ }
88
+
89
+ showLabels := $derived labels !== false && labels !== 'false' && points.length > 0
90
+ </script>
91
+
92
+ <style>
93
+ .chart-root {
94
+ display: grid;
95
+ gap: 0.25rem;
96
+ margin: 0;
97
+ }
98
+ svg {
99
+ width: 100%;
100
+ height: auto;
101
+ display: block;
102
+ }
103
+ .chart-axis {
104
+ stroke: var(--color-border, color-mix(in oklab, currentColor 20%, transparent));
105
+ stroke-width: 1;
106
+ }
107
+ .chart-bar {
108
+ fill: var(--color-accent, currentColor);
109
+ opacity: 0.85;
110
+ }
111
+ .chart-line {
112
+ fill: none;
113
+ stroke: var(--color-accent, currentColor);
114
+ stroke-width: 2;
115
+ }
116
+ .chart-area {
117
+ fill: var(--color-accent, currentColor);
118
+ opacity: 0.15;
119
+ }
120
+ .chart-dot {
121
+ fill: var(--color-accent, currentColor);
122
+ }
123
+ .chart-labels {
124
+ display: flex;
125
+ justify-content: space-between;
126
+ font-size: 0.6875rem;
127
+ opacity: 0.7;
128
+ }
129
+ </style>
@@ -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>
@@ -0,0 +1,191 @@
1
+ .kanban-root(class!="{rootClasses}")
2
+ +each('cols as col (col.key)')
3
+ section.kanban-column(
4
+ class!="{dropTarget === col.key ? 'kanban-column-active' : ''}"
5
+ ondragover!="{(e) => onDragOver(e, col.key)}"
6
+ ondragleave!="{() => onDragLeave(col.key)}"
7
+ ondrop!="{(e) => onDrop(e, col.key)}"
8
+ )
9
+ header.kanban-column-header
10
+ span.kanban-column-title {col.label}
11
+ span.kanban-column-count {col.cards.length}
12
+ .kanban-cards
13
+ +if('col.cards.length === 0')
14
+ .kanban-empty
15
+ +if('empty')
16
+ | {@render empty()}
17
+ +if('!empty')
18
+ | {emptyMessage}
19
+ +each('col.cards as card, i (cardId(card) ?? i)')
20
+ article.kanban-card(
21
+ draggable!="{movable}"
22
+ ondragstart!="{(e) => onDragStart(e, card, col.key)}"
23
+ onclick!="{oncardclick ? () => oncardclick(card) : undefined}"
24
+ class!="{oncardclick ? 'kanban-card-clickable' : ''}"
25
+ )
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]}
32
+
33
+ <script>
34
+ import { cn } from '@human-synthesis/norns-ui/cn'
35
+ import { optimisticOverlay } from '@human-synthesis/norns-ui/behaviors/optimistic.svelte.js'
36
+
37
+ {
38
+ data = {}
39
+ columns = undefined
40
+ title = undefined
41
+ subtitle = undefined
42
+ onMove = undefined
43
+ oncardclick = undefined
44
+ emptyMessage = 'No cards'
45
+ card: renderCard = undefined
46
+ empty = undefined
47
+ class: extra = ''
48
+ } .= $props()
49
+
50
+ // local overlay so a drop lands instantly; server truth wins on reload
51
+ moves := optimisticOverlay()
52
+
53
+ movable := $derived onMove !== undefined
54
+ rootClasses := $derived cn('kanban', extra)
55
+
56
+ labelOf := (key) => {
57
+ const spaced = String(key).replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2')
58
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
59
+ }
60
+
61
+ cardId := (card) => card?.id ?? card?.key ?? undefined
62
+
63
+ titleOf := (card) => {
64
+ if title then return card?.[title]
65
+ card?.title ?? card?.subject ?? card?.name ?? cardId(card) ?? ''
66
+ }
67
+
68
+ grouped := $derived (data && typeof data === 'object' && !Array.isArray(data)) ? data : {}
69
+
70
+ keys := $derived.by => {
71
+ const source = columns ?? Object.keys(grouped)
72
+ return typeof source === 'string'
73
+ ? source.split(',').map((s) => s.trim()).filter(Boolean)
74
+ : source
75
+ }
76
+
77
+ cols := $derived.by => {
78
+ const out = keys.map (key) =>
79
+ { key, label: labelOf(key), cards: [] }
80
+ const byKey = new Map(out.map((c) => [c.key, c]))
81
+ for (const [key, cards] of Object.entries(grouped))
82
+ for (const card of Array.isArray(cards) ? cards : [])
83
+ const to = moves.get(cardId(card), key)
84
+ byKey.get(to)?.cards.push(card)
85
+ out
86
+ }
87
+
88
+ dragged .= $state(undefined)
89
+ dropTarget .= $state(undefined)
90
+
91
+ onDragStart := (e, card, from) =>
92
+ dragged = { card, from }
93
+ e.dataTransfer?.setData('text/plain', String(cardId(card) ?? ''))
94
+ if e.dataTransfer then e.dataTransfer.effectAllowed = 'move'
95
+
96
+ onDragOver := (e, key) =>
97
+ return unless movable and dragged
98
+ e.preventDefault()
99
+ dropTarget = key
100
+
101
+ onDragLeave := (key) =>
102
+ if dropTarget === key then dropTarget = undefined
103
+
104
+ submitMove := (card, to) =>
105
+ if typeof onMove is 'function'
106
+ onMove(card, to)
107
+ else if typeof onMove is 'string'
108
+ // generated pages bind onMove to a "?/action" form-action URL; the
109
+ // action's input contract is strict, so only `id` goes over the wire —
110
+ // the transition itself is what the bound action means by "move"
111
+ const body = new FormData()
112
+ body.set('id', String(cardId(card) ?? ''))
113
+ fetch(onMove, { method: 'POST', body }).catch(=> undefined)
114
+
115
+ onDrop := (e, to) =>
116
+ dropTarget = undefined
117
+ return unless dragged
118
+ e.preventDefault()
119
+ { card, from } := dragged
120
+ dragged = undefined
121
+ return if from === to
122
+ moves.set(cardId(card), to)
123
+ submitMove(card, to)
124
+ </script>
125
+
126
+ <style>
127
+ .kanban-root {
128
+ display: grid;
129
+ grid-auto-flow: column;
130
+ grid-auto-columns: minmax(14rem, 1fr);
131
+ gap: var(--spacing-3, 0.75rem);
132
+ align-items: start;
133
+ overflow-x: auto;
134
+ }
135
+ .kanban-column {
136
+ background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
137
+ border-radius: var(--radius-lg, 0.5rem);
138
+ padding: var(--spacing-2, 0.5rem);
139
+ display: grid;
140
+ gap: var(--spacing-2, 0.5rem);
141
+ }
142
+ .kanban-column-active {
143
+ outline: 2px dashed var(--color-accent, currentColor);
144
+ outline-offset: -2px;
145
+ }
146
+ .kanban-column-header {
147
+ display: flex;
148
+ align-items: center;
149
+ justify-content: space-between;
150
+ font-size: 0.8125rem;
151
+ font-weight: 600;
152
+ padding-inline: var(--spacing-1, 0.25rem);
153
+ }
154
+ .kanban-column-count {
155
+ opacity: 0.6;
156
+ font-variant-numeric: tabular-nums;
157
+ }
158
+ .kanban-cards {
159
+ display: grid;
160
+ gap: var(--spacing-2, 0.5rem);
161
+ min-height: 2rem;
162
+ }
163
+ .kanban-card {
164
+ background: var(--color-surface, canvas);
165
+ border: 1px solid var(--color-border, color-mix(in oklab, currentColor 12%, transparent));
166
+ border-radius: var(--radius-md, 0.375rem);
167
+ padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
168
+ display: grid;
169
+ gap: 0.125rem;
170
+ }
171
+ .kanban-card[draggable='true'] {
172
+ cursor: grab;
173
+ }
174
+ .kanban-card-clickable {
175
+ cursor: pointer;
176
+ }
177
+ .kanban-card-title {
178
+ font-size: 0.875rem;
179
+ font-weight: 500;
180
+ }
181
+ .kanban-card-subtitle {
182
+ font-size: 0.75rem;
183
+ opacity: 0.7;
184
+ }
185
+ .kanban-empty {
186
+ font-size: 0.75rem;
187
+ opacity: 0.6;
188
+ padding: var(--spacing-2, 0.5rem);
189
+ text-align: center;
190
+ }
191
+ </style>