@miadi/episode-ui 0.1.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.
@@ -0,0 +1,400 @@
1
+ /**
2
+ * One stylesheet, plain class names, every colour a custom property the host
3
+ * sets through `themeVars(theme)`. A surface that wants a different look
4
+ * overrides the properties or the classes; nothing here is !important and
5
+ * nothing assumes a framework.
6
+ *
7
+ * Touch targets are 40px because the first surface this runs on is a phone.
8
+ */
9
+
10
+ .episode-file-browser,
11
+ .episode-text-editor,
12
+ .episode-text-viewer,
13
+ .ceremony-note-editor,
14
+ .ceremony-attachments,
15
+ .episode-shelf {
16
+ color: var(--episode-text);
17
+ font-size: 13px;
18
+ line-height: 1.5;
19
+ }
20
+
21
+ /* ---------- file browser ---------- */
22
+
23
+ .episode-file-browser-head {
24
+ display: flex;
25
+ align-items: baseline;
26
+ justify-content: space-between;
27
+ gap: 10px;
28
+ margin-bottom: 8px;
29
+ }
30
+
31
+ .episode-file-browser-title {
32
+ margin: 0;
33
+ font-size: 13px;
34
+ font-weight: 800;
35
+ letter-spacing: 0.02em;
36
+ text-transform: uppercase;
37
+ color: var(--episode-text-dim);
38
+ }
39
+
40
+ .episode-file-browser-count,
41
+ .episode-shelf-count {
42
+ font-family: var(--episode-mono);
43
+ font-size: 11px;
44
+ color: var(--episode-muted);
45
+ }
46
+
47
+ .episode-file-browser-filter,
48
+ .episode-shelf-filter {
49
+ width: 100%;
50
+ min-height: 40px;
51
+ padding: 8px 10px;
52
+ margin-bottom: 8px;
53
+ background: var(--episode-bg);
54
+ color: var(--episode-text);
55
+ border: 1px solid var(--episode-border);
56
+ border-radius: 8px;
57
+ font: inherit;
58
+ }
59
+
60
+ .episode-file-browser-list,
61
+ .episode-shelf-list,
62
+ .ceremony-attachments-list {
63
+ list-style: none;
64
+ margin: 0;
65
+ padding: 0;
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 2px;
69
+ }
70
+
71
+ .episode-file-row {
72
+ display: flex;
73
+ align-items: center;
74
+ gap: 6px;
75
+ }
76
+
77
+ .episode-file-open {
78
+ flex: 1;
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 9px;
82
+ min-height: 40px;
83
+ padding: 6px 9px;
84
+ background: transparent;
85
+ color: inherit;
86
+ border: 1px solid transparent;
87
+ border-radius: 7px;
88
+ font: inherit;
89
+ text-align: left;
90
+ cursor: pointer;
91
+ }
92
+
93
+ .episode-file-open:hover:not(:disabled),
94
+ .episode-shelf-entry:hover {
95
+ background: var(--episode-card);
96
+ border-color: var(--episode-border);
97
+ }
98
+
99
+ .episode-file-open:disabled {
100
+ cursor: default;
101
+ opacity: 0.55;
102
+ }
103
+
104
+ .episode-file-row.is-selected .episode-file-open {
105
+ background: var(--episode-card);
106
+ border-color: var(--episode-accent);
107
+ }
108
+
109
+ .episode-file-row.is-guidance .episode-file-name {
110
+ color: var(--episode-muted);
111
+ }
112
+
113
+ .episode-file-icon {
114
+ flex: none;
115
+ font-size: 14px;
116
+ }
117
+
118
+ .episode-file-name {
119
+ flex: 1;
120
+ overflow-wrap: anywhere;
121
+ }
122
+
123
+ .episode-file-meta,
124
+ .ceremony-attachment-meta {
125
+ flex: none;
126
+ font-family: var(--episode-mono);
127
+ font-size: 10.5px;
128
+ color: var(--episode-muted);
129
+ }
130
+
131
+ .episode-file-attach,
132
+ .ceremony-attachment-detach,
133
+ .episode-text-viewer-toggle {
134
+ min-height: 40px;
135
+ padding: 4px 10px;
136
+ background: transparent;
137
+ color: var(--episode-text-dim);
138
+ border: 1px solid var(--episode-border);
139
+ border-radius: 999px;
140
+ font-family: var(--episode-mono);
141
+ font-size: 10.5px;
142
+ cursor: pointer;
143
+ }
144
+
145
+ .episode-file-attach[aria-pressed="true"] {
146
+ color: var(--episode-accent);
147
+ border-color: var(--episode-accent);
148
+ }
149
+
150
+ .episode-file-browser-empty,
151
+ .ceremony-attachments-empty,
152
+ .episode-file-browser-notice {
153
+ margin: 6px 0 0;
154
+ color: var(--episode-muted);
155
+ font-size: 12px;
156
+ }
157
+
158
+ /* ---------- text editor and viewer ---------- */
159
+
160
+ .episode-text-editor,
161
+ .episode-text-viewer {
162
+ border: 1px solid var(--episode-border);
163
+ border-radius: 10px;
164
+ background: var(--episode-card);
165
+ padding: 10px 12px;
166
+ }
167
+
168
+ .episode-text-editor-head,
169
+ .episode-text-viewer-head,
170
+ .ceremony-note-head {
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: space-between;
174
+ gap: 10px;
175
+ margin-bottom: 8px;
176
+ }
177
+
178
+ .episode-text-editor-file,
179
+ .episode-text-viewer-file {
180
+ font-family: var(--episode-mono);
181
+ font-size: 11.5px;
182
+ color: var(--episode-text-dim);
183
+ overflow-wrap: anywhere;
184
+ }
185
+
186
+ .episode-text-editor-state {
187
+ flex: none;
188
+ font-family: var(--episode-mono);
189
+ font-size: 10.5px;
190
+ color: var(--episode-muted);
191
+ }
192
+
193
+ .episode-text-editor-state.is-saved {
194
+ color: var(--episode-success);
195
+ }
196
+
197
+ .episode-text-editor-state.is-conflict,
198
+ .episode-text-editor-state.is-error {
199
+ color: var(--episode-warning);
200
+ }
201
+
202
+ .episode-text-editor-message {
203
+ margin: 0 0 8px;
204
+ padding: 8px 10px;
205
+ border-radius: 8px;
206
+ font-size: 12px;
207
+ background: var(--episode-bg);
208
+ border: 1px solid var(--episode-border);
209
+ }
210
+
211
+ .episode-text-editor-message.is-conflict,
212
+ .episode-text-editor-message.is-error {
213
+ border-color: var(--episode-warning);
214
+ }
215
+
216
+ .episode-text-editor-their-revision {
217
+ color: var(--episode-muted);
218
+ font-family: var(--episode-mono);
219
+ font-size: 11px;
220
+ }
221
+
222
+ .episode-text-editor-field,
223
+ .ceremony-note-field {
224
+ width: 100%;
225
+ min-height: 220px;
226
+ padding: 10px;
227
+ background: var(--episode-bg);
228
+ color: var(--episode-text);
229
+ border: 1px solid var(--episode-border);
230
+ border-radius: 8px;
231
+ font-family: var(--episode-mono);
232
+ font-size: 12.5px;
233
+ line-height: 1.6;
234
+ resize: vertical;
235
+ }
236
+
237
+ .episode-text-editor-actions,
238
+ .ceremony-note-actions {
239
+ display: flex;
240
+ align-items: center;
241
+ gap: 10px;
242
+ margin-top: 8px;
243
+ flex-wrap: wrap;
244
+ }
245
+
246
+ .episode-text-editor-actions button,
247
+ .ceremony-note-actions button {
248
+ min-height: 40px;
249
+ padding: 6px 14px;
250
+ background: var(--episode-accent);
251
+ color: #1a1a1a;
252
+ border: none;
253
+ border-radius: 8px;
254
+ font: inherit;
255
+ font-weight: 700;
256
+ cursor: pointer;
257
+ }
258
+
259
+ .episode-text-editor-actions button:disabled,
260
+ .ceremony-note-actions button:disabled {
261
+ opacity: 0.45;
262
+ cursor: default;
263
+ }
264
+
265
+ .episode-text-editor-reload {
266
+ background: transparent !important;
267
+ color: var(--episode-warning) !important;
268
+ border: 1px solid var(--episode-warning) !important;
269
+ font-weight: 400 !important;
270
+ }
271
+
272
+ .episode-text-viewer-source {
273
+ margin: 0;
274
+ max-height: 60vh;
275
+ overflow: auto;
276
+ white-space: pre-wrap;
277
+ overflow-wrap: anywhere;
278
+ font-family: var(--episode-mono);
279
+ font-size: 12.5px;
280
+ color: var(--episode-text-dim);
281
+ }
282
+
283
+ .episode-text-viewer-rich {
284
+ font-size: 14px;
285
+ line-height: 1.65;
286
+ }
287
+
288
+ /* ---------- ceremony ---------- */
289
+
290
+ .ceremony-note-label {
291
+ font-size: 12px;
292
+ font-weight: 700;
293
+ color: var(--episode-text-dim);
294
+ }
295
+
296
+ .ceremony-note-state {
297
+ font-family: var(--episode-mono);
298
+ font-size: 10.5px;
299
+ color: var(--episode-muted);
300
+ }
301
+
302
+ .ceremony-note-state.is-saved {
303
+ color: var(--episode-success);
304
+ }
305
+
306
+ .ceremony-note-message.is-error {
307
+ color: var(--episode-warning);
308
+ }
309
+
310
+ .ceremony-attachments {
311
+ margin-top: 10px;
312
+ }
313
+
314
+ .ceremony-attachments-title {
315
+ display: block;
316
+ font-size: 11px;
317
+ font-weight: 800;
318
+ letter-spacing: 0.03em;
319
+ text-transform: uppercase;
320
+ color: var(--episode-muted);
321
+ margin-bottom: 5px;
322
+ }
323
+
324
+ .ceremony-attachment {
325
+ display: flex;
326
+ align-items: center;
327
+ gap: 8px;
328
+ flex-wrap: wrap;
329
+ }
330
+
331
+ .ceremony-attachment-open {
332
+ min-height: 40px;
333
+ padding: 2px 0;
334
+ background: none;
335
+ border: none;
336
+ color: var(--episode-accent);
337
+ font-family: var(--episode-mono);
338
+ font-size: 12px;
339
+ text-align: left;
340
+ cursor: pointer;
341
+ overflow-wrap: anywhere;
342
+ }
343
+
344
+ .ceremony-attachment-open:disabled {
345
+ color: var(--episode-text-dim);
346
+ cursor: default;
347
+ }
348
+
349
+ .ceremony-attachment-note {
350
+ width: 100%;
351
+ color: var(--episode-text-dim);
352
+ font-size: 12px;
353
+ }
354
+
355
+ /* ---------- shelf ---------- */
356
+
357
+ .episode-shelf-entry {
358
+ display: flex;
359
+ align-items: flex-start;
360
+ gap: 10px;
361
+ width: 100%;
362
+ min-height: 40px;
363
+ padding: 8px 10px;
364
+ background: transparent;
365
+ color: inherit;
366
+ border: 1px solid transparent;
367
+ border-radius: 8px;
368
+ font: inherit;
369
+ text-align: left;
370
+ cursor: pointer;
371
+ }
372
+
373
+ .episode-shelf-number {
374
+ flex: none;
375
+ font-family: var(--episode-mono);
376
+ font-weight: 700;
377
+ color: var(--episode-accent);
378
+ }
379
+
380
+ .episode-shelf-body {
381
+ display: flex;
382
+ flex-direction: column;
383
+ gap: 2px;
384
+ }
385
+
386
+ .episode-shelf-body small {
387
+ font-family: var(--episode-mono);
388
+ font-size: 10.5px;
389
+ color: var(--episode-muted);
390
+ }
391
+
392
+ .episode-file-open:focus-visible,
393
+ .episode-file-attach:focus-visible,
394
+ .episode-shelf-entry:focus-visible,
395
+ .ceremony-attachment-open:focus-visible,
396
+ .episode-text-editor-actions button:focus-visible,
397
+ .ceremony-note-actions button:focus-visible {
398
+ outline: 2px solid var(--episode-accent);
399
+ outline-offset: 2px;
400
+ }
@@ -0,0 +1,154 @@
1
+ "use client"
2
+
3
+ import { useMemo, useState } from "react"
4
+ import type { CSSProperties } from "react"
5
+ import {
6
+ DEFAULT_THEME,
7
+ formatBytes,
8
+ iconFor,
9
+ themeVars,
10
+ type EpisodeCapabilitiesView,
11
+ type EpisodeFileView,
12
+ type EpisodeUITheme,
13
+ } from "./types.js"
14
+
15
+ export interface EpisodeFileBrowserProps {
16
+ files: EpisodeFileView[]
17
+ capabilities: EpisodeCapabilitiesView
18
+ /** The file currently open, by `relativePath`. */
19
+ selected?: string | null
20
+ onSelect?: (file: EpisodeFileView) => void
21
+ /** Offered per file when the caller can act on a ceremony. */
22
+ onAttach?: (file: EpisodeFileView) => void
23
+ /** Already attached to the ceremony in context, by `relativePath`. */
24
+ attached?: string[]
25
+ theme?: EpisodeUITheme
26
+ title?: string
27
+ /** Shown when the vessel holds no listable file. */
28
+ emptyLabel?: string
29
+ className?: string
30
+ style?: CSSProperties
31
+ }
32
+
33
+ /**
34
+ * The files in one vessel, as a list a person can act on.
35
+ *
36
+ * It fetches nothing. The host lists the files and says what this person may
37
+ * do; the browser decides only what to show and raises intent. A capability
38
+ * that is absent removes its control rather than disabling it — a button a
39
+ * person can never press is noise, not information.
40
+ */
41
+ export function EpisodeFileBrowser({
42
+ files,
43
+ capabilities,
44
+ selected = null,
45
+ onSelect,
46
+ onAttach,
47
+ attached = [],
48
+ theme = DEFAULT_THEME,
49
+ title = "Files in this episode",
50
+ emptyLabel = "This episode folder holds no readable file yet.",
51
+ className,
52
+ style,
53
+ }: EpisodeFileBrowserProps) {
54
+ const [query, setQuery] = useState("")
55
+
56
+ const shown = useMemo(() => {
57
+ const needle = query.trim().toLowerCase()
58
+ if (!needle) return files
59
+ return files.filter((file) => file.relativePath.toLowerCase().includes(needle))
60
+ }, [files, query])
61
+
62
+ if (!capabilities.browse) return null
63
+
64
+ const attachedSet = new Set(attached)
65
+
66
+ return (
67
+ <section
68
+ className={["episode-file-browser", className].filter(Boolean).join(" ")}
69
+ style={{ ...themeVars(theme), ...style } as CSSProperties}
70
+ aria-label={title}
71
+ >
72
+ <header className="episode-file-browser-head">
73
+ <h3 className="episode-file-browser-title">{title}</h3>
74
+ <span className="episode-file-browser-count">
75
+ {files.length} {files.length === 1 ? "file" : "files"}
76
+ </span>
77
+ </header>
78
+
79
+ {files.length > 6 && (
80
+ <input
81
+ className="episode-file-browser-filter"
82
+ type="search"
83
+ value={query}
84
+ placeholder="Filter by name"
85
+ aria-label="Filter files"
86
+ onChange={(event) => setQuery(event.target.value)}
87
+ />
88
+ )}
89
+
90
+ {files.length === 0 ? (
91
+ <p className="episode-file-browser-empty">{emptyLabel}</p>
92
+ ) : (
93
+ <ul className="episode-file-browser-list">
94
+ {shown.map((file) => {
95
+ const isSelected = file.relativePath === selected
96
+ const canOpen = capabilities.read && (file.previewable || file.kind !== "text")
97
+ return (
98
+ <li
99
+ key={file.relativePath}
100
+ className={[
101
+ "episode-file-row",
102
+ isSelected ? "is-selected" : "",
103
+ file.guidance ? "is-guidance" : "",
104
+ ]
105
+ .filter(Boolean)
106
+ .join(" ")}
107
+ >
108
+ <button
109
+ type="button"
110
+ className="episode-file-open"
111
+ disabled={!canOpen}
112
+ aria-current={isSelected ? "true" : undefined}
113
+ onClick={() => onSelect?.(file)}
114
+ >
115
+ <span className="episode-file-icon" aria-hidden="true">
116
+ {iconFor(file)}
117
+ </span>
118
+ <span className="episode-file-name">{file.relativePath}</span>
119
+ <span className="episode-file-meta">
120
+ {formatBytes(file.size)}
121
+ {file.editable && capabilities.edit ? " · editable" : ""}
122
+ {file.kind === "document" ? " · download" : ""}
123
+ {file.guidance ? " · guidance" : ""}
124
+ </span>
125
+ </button>
126
+ {onAttach && capabilities.attachToCeremony && (
127
+ <button
128
+ type="button"
129
+ className="episode-file-attach"
130
+ aria-pressed={attachedSet.has(file.relativePath)}
131
+ title={
132
+ attachedSet.has(file.relativePath)
133
+ ? "On this ceremony's table"
134
+ : "Name this file as being on the ceremony's table"
135
+ }
136
+ onClick={() => onAttach(file)}
137
+ >
138
+ {attachedSet.has(file.relativePath) ? "on the table" : "attach"}
139
+ </button>
140
+ )}
141
+ </li>
142
+ )
143
+ })}
144
+ </ul>
145
+ )}
146
+
147
+ {!capabilities.read && (
148
+ <p className="episode-file-browser-notice">
149
+ Sign in to read these files. What is here is listed for everyone; what it says is not.
150
+ </p>
151
+ )}
152
+ </section>
153
+ )
154
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @miadi/episode-ui — the episode-vessel experience, as React.
3
+ *
4
+ * gmtermux proved this experience on a phone in vanilla JS
5
+ * (`web/lib/file-browser.js`, `web/lib/fullscreen-editor.js`,
6
+ * `web/lib/episode-shelf-filter.js`) where no React surface could reach it. The
7
+ * Miadi episode room meanwhile rendered its files as text it could not open.
8
+ * These components are that experience in a form both can hold.
9
+ *
10
+ * The contract, in one line: **data and callbacks in, intent out.** No
11
+ * component fetches, holds a URL or token, knows a chronicle root, or decides
12
+ * what a person is allowed to do. The host resolves capabilities (see
13
+ * `@miadi/episode-vessel/permissions`) and remains the authority; a capability
14
+ * here decides only what is offered.
15
+ *
16
+ * Styles are one stylesheet of plain classes over CSS custom properties:
17
+ * `import "@miadi/episode-ui/styles.css"`, or write your own against the same
18
+ * class names.
19
+ */
20
+
21
+ export { EpisodeFileBrowser, type EpisodeFileBrowserProps } from "./file-browser.js"
22
+ export {
23
+ EpisodeTextEditor,
24
+ EpisodeTextViewer,
25
+ type EpisodeTextEditorProps,
26
+ type EpisodeTextViewerProps,
27
+ type SaveOutcome,
28
+ } from "./text-editor.js"
29
+ export {
30
+ CeremonyNoteEditor,
31
+ CeremonyAttachments,
32
+ type CeremonyNoteEditorProps,
33
+ type CeremonyAttachmentsProps,
34
+ } from "./ceremony.js"
35
+ export { EpisodeShelfFilter, type EpisodeShelfFilterProps } from "./shelf-filter.js"
36
+ export {
37
+ DEFAULT_THEME,
38
+ themeVars,
39
+ formatBytes,
40
+ iconFor,
41
+ type FileKind,
42
+ type EpisodeFileView,
43
+ type FileRevisionView,
44
+ type EpisodeTextView,
45
+ type EpisodeCapabilitiesView,
46
+ type CeremonyAttachmentView,
47
+ type EpisodeShelfEntry,
48
+ type EpisodeUITheme,
49
+ } from "./types.js"
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import { useMemo, useState } from "react"
4
+ import type { CSSProperties } from "react"
5
+ import { DEFAULT_THEME, themeVars, type EpisodeShelfEntry, type EpisodeUITheme } from "./types.js"
6
+
7
+ export interface EpisodeShelfFilterProps {
8
+ episodes: EpisodeShelfEntry[]
9
+ selected?: string | null
10
+ onSelect?: (episode: EpisodeShelfEntry) => void
11
+ /** Render each entry yourself when the host has its own card. */
12
+ renderEntry?: (episode: EpisodeShelfEntry, selected: boolean) => React.ReactNode
13
+ placeholder?: string
14
+ theme?: EpisodeUITheme
15
+ className?: string
16
+ style?: CSSProperties
17
+ }
18
+
19
+ /**
20
+ * Find an episode by number, title or slug.
21
+ *
22
+ * `349`, `ep349`, `episode 349` and a word from the title all reach the same
23
+ * vessel — on a phone, typing the number is the fastest way there, and it was
24
+ * the first thing gmtermux's shelf learned.
25
+ */
26
+ export function EpisodeShelfFilter({
27
+ episodes,
28
+ selected = null,
29
+ onSelect,
30
+ renderEntry,
31
+ placeholder = "Find an episode by number or title",
32
+ theme = DEFAULT_THEME,
33
+ className,
34
+ style,
35
+ }: EpisodeShelfFilterProps) {
36
+ const [query, setQuery] = useState("")
37
+
38
+ const shown = useMemo(() => {
39
+ const needle = query.trim().toLowerCase().replace(/^(?:ep(?:isode)?\s*)/, "")
40
+ if (!needle) return episodes
41
+ return episodes.filter((episode) =>
42
+ [
43
+ String(episode.number),
44
+ episode.numberLabel,
45
+ episode.title,
46
+ episode.episodePath,
47
+ episode.goal ?? "",
48
+ ]
49
+ .join(" ")
50
+ .toLowerCase()
51
+ .includes(needle),
52
+ )
53
+ }, [episodes, query])
54
+
55
+ return (
56
+ <div
57
+ className={["episode-shelf", className].filter(Boolean).join(" ")}
58
+ style={{ ...themeVars(theme), ...style } as CSSProperties}
59
+ >
60
+ <input
61
+ className="episode-shelf-filter"
62
+ type="search"
63
+ value={query}
64
+ placeholder={placeholder}
65
+ aria-label={placeholder}
66
+ onChange={(event) => setQuery(event.target.value)}
67
+ />
68
+ <span className="episode-shelf-count">
69
+ {shown.length} of {episodes.length}
70
+ </span>
71
+ <ul className="episode-shelf-list">
72
+ {shown.map((episode) => {
73
+ const isSelected = episode.episodePath === selected
74
+ return (
75
+ <li key={episode.episodePath} className={isSelected ? "is-selected" : undefined}>
76
+ {renderEntry ? (
77
+ renderEntry(episode, isSelected)
78
+ ) : (
79
+ <button type="button" className="episode-shelf-entry" onClick={() => onSelect?.(episode)}>
80
+ <span className="episode-shelf-number">{episode.numberLabel}</span>
81
+ <span className="episode-shelf-body">
82
+ <strong>{episode.title}</strong>
83
+ {episode.goal && <span>{episode.goal}</span>}
84
+ <small>
85
+ {episode.date}
86
+ {episode.status ? ` · ${episode.status}` : ""}
87
+ {episode.registered === false ? " · vessel only" : ""}
88
+ </small>
89
+ </span>
90
+ </button>
91
+ )}
92
+ </li>
93
+ )
94
+ })}
95
+ </ul>
96
+ </div>
97
+ )
98
+ }