@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,78 @@
1
+ /**
2
+ * The shapes these components take in.
3
+ *
4
+ * They are re-declared structurally rather than imported from
5
+ * `@miadi/episode-vessel` at runtime: a browser bundle must not pull `node:fs`
6
+ * in, and a host that reaches a vessel over HTTP receives JSON, not the
7
+ * package's classes. The vessel package is a type-only dependency here, and the
8
+ * two definitions are kept honest by `test/contract.test.mjs`.
9
+ */
10
+ export type FileKind = "text" | "document" | "media";
11
+ export interface EpisodeFileView {
12
+ relativePath: string;
13
+ name: string;
14
+ kind: FileKind;
15
+ size: number;
16
+ modifiedAt: string;
17
+ previewable: boolean;
18
+ editable: boolean;
19
+ guidance: boolean;
20
+ }
21
+ export interface FileRevisionView {
22
+ sha256: string;
23
+ modifiedAt: string;
24
+ bytes: number;
25
+ }
26
+ export interface EpisodeTextView {
27
+ content: string;
28
+ contentType: string;
29
+ size: number;
30
+ revision: FileRevisionView;
31
+ }
32
+ export interface EpisodeCapabilitiesView {
33
+ browse: boolean;
34
+ read: boolean;
35
+ edit: boolean;
36
+ upload: boolean;
37
+ attachCapture: boolean;
38
+ writeCeremonyNote: boolean;
39
+ attachToCeremony: boolean;
40
+ }
41
+ export interface CeremonyAttachmentView {
42
+ relativePath: string;
43
+ attachedBy: string;
44
+ attachedAt: string;
45
+ note?: string;
46
+ }
47
+ export interface EpisodeShelfEntry {
48
+ episodePath: string;
49
+ number: number;
50
+ numberLabel: string;
51
+ title: string;
52
+ date: string;
53
+ goal?: string;
54
+ status?: string;
55
+ /** True when the medicine wheel also holds this episode. */
56
+ registered?: boolean;
57
+ }
58
+ /** Colours and fonts the host already has. Every component takes the same set. */
59
+ export interface EpisodeUITheme {
60
+ background: string;
61
+ card: string;
62
+ text: string;
63
+ textDim: string;
64
+ muted: string;
65
+ accent: string;
66
+ border: string;
67
+ success: string;
68
+ warning: string;
69
+ mono: string;
70
+ }
71
+ export declare const DEFAULT_THEME: EpisodeUITheme;
72
+ export declare function themeVars(theme: EpisodeUITheme): Record<string, string>;
73
+ export declare function formatBytes(bytes: number): string;
74
+ export declare function iconFor(file: {
75
+ name: string;
76
+ kind: FileKind;
77
+ }): string;
78
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,UAAU,GAAG,OAAO,CAAA;AAEpD,MAAM,WAAW,eAAe;IAC9B,YAAY,EAAE,MAAM,CAAA;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,QAAQ,CAAA;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,OAAO,CAAA;IACpB,QAAQ,EAAE,OAAO,CAAA;IACjB,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,gBAAgB,CAAA;CAC3B;AAED,MAAM,WAAW,uBAAuB;IACtC,MAAM,EAAE,OAAO,CAAA;IACf,IAAI,EAAE,OAAO,CAAA;IACb,IAAI,EAAE,OAAO,CAAA;IACb,MAAM,EAAE,OAAO,CAAA;IACf,aAAa,EAAE,OAAO,CAAA;IACtB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,gBAAgB,EAAE,OAAO,CAAA;CAC1B;AAED,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,MAAM,CAAA;IACpB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4DAA4D;IAC5D,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAA;IAClB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb;AAED,eAAO,MAAM,aAAa,EAAE,cAW3B,CAAA;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAavE;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKjD;AAED,wBAAgB,OAAO,CAAC,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAAG,MAAM,CAUtE"}
package/dist/types.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The shapes these components take in.
3
+ *
4
+ * They are re-declared structurally rather than imported from
5
+ * `@miadi/episode-vessel` at runtime: a browser bundle must not pull `node:fs`
6
+ * in, and a host that reaches a vessel over HTTP receives JSON, not the
7
+ * package's classes. The vessel package is a type-only dependency here, and the
8
+ * two definitions are kept honest by `test/contract.test.mjs`.
9
+ */
10
+ export const DEFAULT_THEME = {
11
+ background: "#0d0f12",
12
+ card: "#15181d",
13
+ text: "#e8e6e1",
14
+ textDim: "#b3afa6",
15
+ muted: "#7d7a72",
16
+ accent: "#ffd700",
17
+ border: "#2a2e35",
18
+ success: "#5ac37d",
19
+ warning: "#e0a458",
20
+ mono: "ui-monospace, SFMono-Regular, Menlo, monospace",
21
+ };
22
+ export function themeVars(theme) {
23
+ return {
24
+ "--episode-bg": theme.background,
25
+ "--episode-card": theme.card,
26
+ "--episode-text": theme.text,
27
+ "--episode-text-dim": theme.textDim,
28
+ "--episode-muted": theme.muted,
29
+ "--episode-accent": theme.accent,
30
+ "--episode-border": theme.border,
31
+ "--episode-success": theme.success,
32
+ "--episode-warning": theme.warning,
33
+ "--episode-mono": theme.mono,
34
+ };
35
+ }
36
+ export function formatBytes(bytes) {
37
+ if (!Number.isFinite(bytes) || bytes < 0)
38
+ return "";
39
+ if (bytes < 1024)
40
+ return `${bytes} B`;
41
+ if (bytes < 1024 * 1024)
42
+ return `${(bytes / 1024).toFixed(1)} KB`;
43
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
44
+ }
45
+ export function iconFor(file) {
46
+ const extension = /\.([^.]+)$/.exec(file.name)?.[1]?.toLowerCase() ?? "";
47
+ if (["m4a", "mp3", "wav", "opus", "aac", "amr", "ogg"].includes(extension))
48
+ return "🎙";
49
+ if (["mp4", "webm", "mov"].includes(extension))
50
+ return "🎬";
51
+ if (extension === "mid")
52
+ return "🎹";
53
+ if (extension === "md")
54
+ return "📘";
55
+ if (["json", "yaml", "yml"].includes(extension))
56
+ return "🧾";
57
+ if (["html", "htm"].includes(extension))
58
+ return "🖼";
59
+ if (extension === "pdf")
60
+ return "📕";
61
+ return "📄";
62
+ }
63
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuEH,MAAM,CAAC,MAAM,aAAa,GAAmB;IAC3C,UAAU,EAAE,SAAS;IACrB,IAAI,EAAE,SAAS;IACf,IAAI,EAAE,SAAS;IACf,OAAO,EAAE,SAAS;IAClB,KAAK,EAAE,SAAS;IAChB,MAAM,EAAE,SAAS;IACjB,MAAM,EAAE,SAAS;IACjB,OAAO,EAAE,SAAS;IAClB,OAAO,EAAE,SAAS;IAClB,IAAI,EAAE,gDAAgD;CACvD,CAAA;AAED,MAAM,UAAU,SAAS,CAAC,KAAqB;IAC7C,OAAO;QACL,cAAc,EAAE,KAAK,CAAC,UAAU;QAChC,gBAAgB,EAAE,KAAK,CAAC,IAAI;QAC5B,gBAAgB,EAAE,KAAK,CAAC,IAAI;QAC5B,oBAAoB,EAAE,KAAK,CAAC,OAAO;QACnC,iBAAiB,EAAE,KAAK,CAAC,KAAK;QAC9B,kBAAkB,EAAE,KAAK,CAAC,MAAM;QAChC,kBAAkB,EAAE,KAAK,CAAC,MAAM;QAChC,mBAAmB,EAAE,KAAK,CAAC,OAAO;QAClC,mBAAmB,EAAE,KAAK,CAAC,OAAO;QAClC,gBAAgB,EAAE,KAAK,CAAC,IAAI;KAC7B,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAa;IACvC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,EAAE,CAAA;IACnD,IAAI,KAAK,GAAG,IAAI;QAAE,OAAO,GAAG,KAAK,IAAI,CAAA;IACrC,IAAI,KAAK,GAAG,IAAI,GAAG,IAAI;QAAE,OAAO,GAAG,CAAC,KAAK,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAA;IACjE,OAAO,GAAG,CAAC,KAAK,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAA;AACnD,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,IAAsC;IAC5D,MAAM,SAAS,GAAG,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAA;IACxE,IAAI,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IACvF,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IAC3D,IAAI,SAAS,KAAK,KAAK;QAAE,OAAO,IAAI,CAAA;IACpC,IAAI,SAAS,KAAK,IAAI;QAAE,OAAO,IAAI,CAAA;IACnC,IAAI,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IAC5D,IAAI,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC;QAAE,OAAO,IAAI,CAAA;IACpD,IAAI,SAAS,KAAK,KAAK;QAAE,OAAO,IAAI,CAAA;IACpC,OAAO,IAAI,CAAA;AACb,CAAC"}
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@miadi/episode-ui",
3
+ "version": "0.1.0",
4
+ "description": "Host-driven React components for an episode vessel: browse its files, read one, edit one with a conflict that never loses the draft, keep a ceremony's working notes and name the artefacts a circle was held about. No component fetches, holds a URL or a token, or knows a chronicle root — files, content, capabilities and callbacks come in; intent comes out.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "sideEffects": [
11
+ "**/*.css"
12
+ ],
13
+ "style": "./src/episode-ui.css",
14
+ "main": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./styles.css": "./src/episode-ui.css",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "dependencies": {
31
+ "@miadi/episode-vessel": "^0.1.0"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=18",
35
+ "react-dom": ">=18"
36
+ },
37
+ "devDependencies": {
38
+ "@types/react": "^19",
39
+ "@types/react-dom": "^19",
40
+ "react": "^19",
41
+ "react-dom": "^19",
42
+ "typescript": "^5.4.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=20"
46
+ },
47
+ "keywords": [
48
+ "miadi",
49
+ "chronicle",
50
+ "episode",
51
+ "vessel",
52
+ "react",
53
+ "headless",
54
+ "file-browser",
55
+ "ceremony"
56
+ ],
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/jgwill/Miadi.git",
60
+ "directory": "packages/episode-ui"
61
+ },
62
+ "bugs": {
63
+ "url": "https://github.com/jgwill/Miadi/issues"
64
+ },
65
+ "scripts": {
66
+ "build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
67
+ "type-check": "../../node_modules/.bin/tsc -p tsconfig.build.json --noEmit",
68
+ "test": "npm run build && node --test test/*.test.mjs"
69
+ }
70
+ }
@@ -0,0 +1,185 @@
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import type { CSSProperties } from "react"
5
+ import {
6
+ DEFAULT_THEME,
7
+ themeVars,
8
+ type CeremonyAttachmentView,
9
+ type EpisodeCapabilitiesView,
10
+ type EpisodeUITheme,
11
+ } from "./types.js"
12
+
13
+ export interface CeremonyNoteEditorProps {
14
+ ceremonyId: string
15
+ /** Where the note lives in the vessel, shown so a person can find it later. */
16
+ relativePath: string
17
+ content: string
18
+ exists: boolean
19
+ capabilities: EpisodeCapabilitiesView
20
+ onSave: (content: string) => Promise<{ ok: boolean; message?: string }>
21
+ label?: string
22
+ placeholder?: string
23
+ theme?: EpisodeUITheme
24
+ className?: string
25
+ style?: CSSProperties
26
+ }
27
+
28
+ /**
29
+ * The working notes of one ceremony, kept in the episode vessel.
30
+ *
31
+ * The medicine wheel owns the ceremony record; this is what the circle wrote
32
+ * down, and it stays in the folder so it travels with the episode and survives
33
+ * a wheel that is offline. A person without `writeCeremonyNote` reads it.
34
+ */
35
+ export function CeremonyNoteEditor({
36
+ ceremonyId,
37
+ relativePath,
38
+ content,
39
+ exists,
40
+ capabilities,
41
+ onSave,
42
+ label = "Working notes",
43
+ placeholder = "What should remain with this ceremony?",
44
+ theme = DEFAULT_THEME,
45
+ className,
46
+ style,
47
+ }: CeremonyNoteEditorProps) {
48
+ const [draft, setDraft] = useState(content)
49
+ const [state, setState] = useState<"idle" | "saving" | "saved" | "error">("idle")
50
+ const [message, setMessage] = useState("")
51
+
52
+ useEffect(() => {
53
+ setDraft(content)
54
+ setState("idle")
55
+ }, [ceremonyId, content])
56
+
57
+ const mayWrite = capabilities.writeCeremonyNote
58
+
59
+ const save = async () => {
60
+ setState("saving")
61
+ try {
62
+ const outcome = await onSave(draft)
63
+ setState(outcome.ok ? "saved" : "error")
64
+ setMessage(outcome.message ?? (outcome.ok ? `Saved to ${relativePath}` : "It could not be saved."))
65
+ } catch (error) {
66
+ setState("error")
67
+ setMessage(error instanceof Error ? error.message : "It could not be saved.")
68
+ }
69
+ }
70
+
71
+ return (
72
+ <div
73
+ className={["ceremony-note-editor", className].filter(Boolean).join(" ")}
74
+ style={{ ...themeVars(theme), ...style } as CSSProperties}
75
+ >
76
+ <div className="ceremony-note-head">
77
+ <label className="ceremony-note-label" htmlFor={`ceremony-note-${ceremonyId}`}>
78
+ {label}
79
+ </label>
80
+ <span className={`ceremony-note-state is-${exists ? "saved" : "pending"}`} title={relativePath}>
81
+ {exists ? "notes.md" : "not saved yet"}
82
+ </span>
83
+ </div>
84
+ <textarea
85
+ id={`ceremony-note-${ceremonyId}`}
86
+ className="ceremony-note-field"
87
+ value={draft}
88
+ maxLength={65536}
89
+ readOnly={!mayWrite}
90
+ placeholder={mayWrite ? placeholder : undefined}
91
+ onChange={(event) => {
92
+ setDraft(event.target.value)
93
+ if (state !== "idle") setState("idle")
94
+ }}
95
+ />
96
+ {mayWrite && (
97
+ <div className="ceremony-note-actions">
98
+ <button type="button" disabled={state === "saving" || draft === content} onClick={save}>
99
+ Save notes
100
+ </button>
101
+ {message && (
102
+ <span className={`ceremony-note-message is-${state}`} role="status" aria-live="polite">
103
+ {message}
104
+ </span>
105
+ )}
106
+ </div>
107
+ )}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ export interface CeremonyAttachmentsProps {
113
+ attachments: CeremonyAttachmentView[]
114
+ capabilities: EpisodeCapabilitiesView
115
+ onOpen?: (relativePath: string) => void
116
+ onDetach?: (relativePath: string) => void
117
+ emptyLabel?: string
118
+ title?: string
119
+ theme?: EpisodeUITheme
120
+ className?: string
121
+ style?: CSSProperties
122
+ }
123
+
124
+ /**
125
+ * What a circle was held about.
126
+ *
127
+ * A ceremony with no attachment shows nothing rather than an empty frame — an
128
+ * absence stated loudly is still noise. When there are attachments, each one
129
+ * says who put it on the table and when, because that is the part a reader
130
+ * cannot reconstruct afterwards.
131
+ */
132
+ export function CeremonyAttachments({
133
+ attachments,
134
+ capabilities,
135
+ onOpen,
136
+ onDetach,
137
+ emptyLabel,
138
+ title = "On the table",
139
+ theme = DEFAULT_THEME,
140
+ className,
141
+ style,
142
+ }: CeremonyAttachmentsProps) {
143
+ if (attachments.length === 0 && !emptyLabel) return null
144
+
145
+ return (
146
+ <div
147
+ className={["ceremony-attachments", className].filter(Boolean).join(" ")}
148
+ style={{ ...themeVars(theme), ...style } as CSSProperties}
149
+ >
150
+ <span className="ceremony-attachments-title">{title}</span>
151
+ {attachments.length === 0 ? (
152
+ <p className="ceremony-attachments-empty">{emptyLabel}</p>
153
+ ) : (
154
+ <ul className="ceremony-attachments-list">
155
+ {attachments.map((item) => (
156
+ <li key={item.relativePath} className="ceremony-attachment">
157
+ <button
158
+ type="button"
159
+ className="ceremony-attachment-open"
160
+ disabled={!capabilities.read || !onOpen}
161
+ onClick={() => onOpen?.(item.relativePath)}
162
+ >
163
+ {item.relativePath}
164
+ </button>
165
+ <span className="ceremony-attachment-meta">
166
+ {item.attachedBy} · {item.attachedAt.slice(0, 16).replace("T", " ")}
167
+ </span>
168
+ {item.note && <span className="ceremony-attachment-note">{item.note}</span>}
169
+ {onDetach && capabilities.attachToCeremony && (
170
+ <button
171
+ type="button"
172
+ className="ceremony-attachment-detach"
173
+ title="Take this off the table"
174
+ onClick={() => onDetach(item.relativePath)}
175
+ >
176
+ remove
177
+ </button>
178
+ )}
179
+ </li>
180
+ ))}
181
+ </ul>
182
+ )}
183
+ </div>
184
+ )
185
+ }