@rohal12/spindle 0.18.1 → 0.20.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": "@rohal12/spindle",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "A Preact-based story format for Twine 2.",
6
6
  "license": "Unlicense",
@@ -1,6 +1,7 @@
1
1
  import { useEffect } from 'preact/hooks';
2
2
  import { useStoryStore } from '../store';
3
3
  import { StoryInterface } from './StoryInterface';
4
+ import { TriggerDialogHost } from './TriggerDialogHost';
4
5
 
5
6
  export function App() {
6
7
  const storyData = useStoryStore((s) => s.storyData);
@@ -24,5 +25,10 @@ export function App() {
24
25
  return <div class="loading">Loading...</div>;
25
26
  }
26
27
 
27
- return <StoryInterface />;
28
+ return (
29
+ <>
30
+ <StoryInterface />
31
+ <TriggerDialogHost />
32
+ </>
33
+ );
28
34
  }
@@ -14,6 +14,7 @@ export function renderPassageContent(passage: PassageData) {
14
14
 
15
15
  interface PassageProps {
16
16
  passage: PassageData;
17
+ dataTransition?: string;
17
18
  }
18
19
 
19
20
  const CODE_PASSAGES = new Set([
@@ -23,7 +24,7 @@ const CODE_PASSAGES = new Set([
23
24
  'PassageDone',
24
25
  ]);
25
26
 
26
- export function Passage({ passage }: PassageProps) {
27
+ export function Passage({ passage, dataTransition }: PassageProps) {
27
28
  const storyData = useStoryStore((s) => s.storyData);
28
29
  const isCodePassage = CODE_PASSAGES.has(passage.name);
29
30
  const [doneReady, setDoneReady] = useState(false);
@@ -92,6 +93,7 @@ export function Passage({ passage }: PassageProps) {
92
93
  class="passage"
93
94
  data-passage={passage.name}
94
95
  data-tags={passage.tags.join(' ')}
96
+ data-transition={dataTransition}
95
97
  >
96
98
  {headerContent && <div class="passage-header">{headerContent}</div>}
97
99
  {content}
@@ -0,0 +1,42 @@
1
+ import { useState, useEffect, useCallback } from 'preact/hooks';
2
+ import { PassageDialog } from './PassageDialog';
3
+ import {
4
+ subscribeTriggerDialogs,
5
+ shiftDialogQueue,
6
+ dialogQueueLength,
7
+ } from '../triggers';
8
+
9
+ export function TriggerDialogHost() {
10
+ const [current, setCurrent] = useState<string | null>(null);
11
+
12
+ const advance = useCallback(() => {
13
+ const next = shiftDialogQueue();
14
+ setCurrent(next ?? null);
15
+ }, []);
16
+
17
+ useEffect(() => {
18
+ return subscribeTriggerDialogs(() => {
19
+ setCurrent((prev) => {
20
+ if (prev !== null) return prev; // already showing one
21
+ return shiftDialogQueue() ?? null;
22
+ });
23
+ });
24
+ }, []);
25
+
26
+ const handleClose = useCallback(() => {
27
+ setCurrent(null);
28
+ // Show next queued dialog after a tick
29
+ if (dialogQueueLength() > 0) {
30
+ requestAnimationFrame(advance);
31
+ }
32
+ }, [advance]);
33
+
34
+ if (!current) return null;
35
+
36
+ return (
37
+ <PassageDialog
38
+ passageName={current}
39
+ onClose={handleClose}
40
+ />
41
+ );
42
+ }
@@ -1,25 +1,40 @@
1
+ import { h, render } from 'preact';
1
2
  import { defineMacro } from '../../define-macro';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ } from '../../markup/render';
2
8
 
3
9
  defineMacro({
4
10
  name: 'button',
5
11
  interpolate: true,
6
12
  render({ rawArgs, children = [] }, ctx) {
13
+ const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
14
+
7
15
  const handleClick = () => {
8
- try {
9
- ctx.mutate(rawArgs);
10
- } catch (err) {
11
- console.error(
12
- `spindle: Error in {button ${rawArgs}}${ctx.sourceLocation()}:`,
13
- err,
14
- );
15
- }
16
+ // Render children into a detached DOM node — all macro side effects
17
+ // ({set}, {if}, {unwatch}, etc.) fire through the normal Preact pipeline.
18
+ // Wrap with locals context so @local variables from for-loops are available.
19
+ const container = document.createElement('div');
20
+ const vnode = h(
21
+ LocalsUpdateContext.Provider,
22
+ { value: { update: ctx.update, getValues: ctx.getValues } },
23
+ h(
24
+ LocalsValuesContext.Provider,
25
+ { value: ctx.getValues() },
26
+ renderNodes(children),
27
+ ),
28
+ );
29
+ render(vnode, container);
30
+ render(null, container);
16
31
  };
17
32
 
18
33
  ctx.useAction({
19
34
  type: 'button',
20
35
  key: rawArgs,
21
36
  authorId: ctx.id,
22
- label: ctx.collectText(children) || rawArgs,
37
+ label,
23
38
  perform: handleClick,
24
39
  });
25
40
 
@@ -29,7 +44,7 @@ defineMacro({
29
44
  class={ctx.cls}
30
45
  onClick={handleClick}
31
46
  >
32
- {ctx.renderInlineNodes(children)}
47
+ {label}
33
48
  </button>
34
49
  );
35
50
  },
@@ -3,21 +3,15 @@ import { PassageDialog } from '../PassageDialog';
3
3
 
4
4
  defineMacro({
5
5
  name: 'dialog',
6
- merged: true,
6
+ interpolate: true,
7
7
  render({ rawArgs, children = [] }, ctx) {
8
8
  const [open, setOpen] = ctx.hooks.useState(false);
9
9
 
10
- let passageName: string;
11
- try {
12
- const result = ctx.evaluate!(rawArgs);
13
- passageName = String(result);
14
- } catch {
15
- passageName = rawArgs.replace(/^["']|["']$/g, '').trim();
16
- }
17
-
18
- const label = children.length
19
- ? ctx.renderInlineNodes(children)
20
- : passageName;
10
+ const label = ctx.resolve?.(rawArgs.replace(/^["']|["']$/g, '')) ?? rawArgs;
11
+ const passageName = ctx
12
+ .collectText(children)
13
+ .trim()
14
+ .replace(/^["']|["']$/g, '');
21
15
 
22
16
  return (
23
17
  <>
@@ -1,8 +1,10 @@
1
+ import { h, render } from 'preact';
1
2
  import { useStoryStore } from '../../store';
2
- import type { ASTNode } from '../../markup/ast';
3
- import { executeMutation } from '../../execute-mutation';
4
- import { collectText } from '../../utils/extract-text';
5
- import { currentSourceLocation } from '../../utils/source-location';
3
+ import {
4
+ renderNodes,
5
+ LocalsUpdateContext,
6
+ LocalsValuesContext,
7
+ } from '../../markup/render';
6
8
  import { defineMacro } from '../../define-macro';
7
9
 
8
10
  function parseArgs(rawArgs: string): {
@@ -26,37 +28,23 @@ function parseArgs(rawArgs: string): {
26
28
  return { display: rawArgs.trim(), passage: null };
27
29
  }
28
30
 
29
- /**
30
- * Execute the children imperatively: walk AST for {set} and {do} macros.
31
- */
32
- function executeChildren(
33
- children: ASTNode[],
34
- mergedLocals: Record<string, unknown>,
35
- scopeUpdate: (key: string, value: unknown) => void,
31
+ function renderChildrenDetached(
32
+ children: import('../../markup/ast').ASTNode[],
33
+ getValues: () => Record<string, unknown>,
34
+ update: (key: string, value: unknown) => void,
36
35
  ) {
37
- for (const node of children) {
38
- if (node.type !== 'macro') continue;
39
- if (node.name === 'set') {
40
- try {
41
- executeMutation(node.rawArgs, mergedLocals, scopeUpdate);
42
- } catch (err) {
43
- console.error(
44
- `spindle: Error in {link} child {set}${currentSourceLocation()}:`,
45
- err,
46
- );
47
- }
48
- } else if (node.name === 'do') {
49
- const code = collectText(node.children);
50
- try {
51
- executeMutation(code, mergedLocals, scopeUpdate);
52
- } catch (err) {
53
- console.error(
54
- `spindle: Error in {link} child {do}${currentSourceLocation()}:`,
55
- err,
56
- );
57
- }
58
- }
59
- }
36
+ const container = document.createElement('div');
37
+ const vnode = h(
38
+ LocalsUpdateContext.Provider,
39
+ { value: { update, getValues } },
40
+ h(
41
+ LocalsValuesContext.Provider,
42
+ { value: getValues() },
43
+ renderNodes(children),
44
+ ),
45
+ );
46
+ render(vnode, container);
47
+ render(null, container);
60
48
  }
61
49
 
62
50
  defineMacro({
@@ -67,7 +55,7 @@ defineMacro({
67
55
 
68
56
  const handleClick = (e: Event) => {
69
57
  e.preventDefault();
70
- executeChildren(children, ctx.getValues(), ctx.update);
58
+ renderChildrenDetached(children, ctx.getValues, ctx.update);
71
59
  if (passage) {
72
60
  useStoryStore.getState().navigate(passage);
73
61
  }
@@ -80,7 +68,7 @@ defineMacro({
80
68
  label: display,
81
69
  target: passage ?? undefined,
82
70
  perform: () => {
83
- executeChildren(children, ctx.getValues(), ctx.update);
71
+ renderChildrenDetached(children, ctx.getValues, ctx.update);
84
72
  if (passage) {
85
73
  useStoryStore.getState().navigate(passage);
86
74
  }
@@ -1,6 +1,69 @@
1
+ import { useRef, useEffect, useCallback, useState } from 'preact/hooks';
1
2
  import { useStoryStore } from '../../store';
2
3
  import { Passage, renderPassageContent } from '../Passage';
3
4
  import { defineMacro } from '../../define-macro';
5
+ import { resolveTransition, type ResolvedTransition } from '../../transition';
6
+
7
+ /**
8
+ * Create a snapshot clone of a .passage element for outgoing transitions.
9
+ * - Strips `id` attributes from the clone tree
10
+ * - Disables interaction (pointer-events, user-select)
11
+ * - Pauses/mutes any <audio>/<video>
12
+ * - Adds `.passage-snapshot` class and `data-transition` attribute
13
+ */
14
+ function createSnapshot(
15
+ passageEl: Element,
16
+ transitionType: string,
17
+ ): HTMLElement {
18
+ const clone = passageEl.cloneNode(true) as HTMLElement;
19
+
20
+ // Strip id attributes to avoid duplicates
21
+ clone.removeAttribute('id');
22
+ const idEls = clone.querySelectorAll('[id]');
23
+ for (let i = 0; i < idEls.length; i++) {
24
+ idEls[i]!.removeAttribute('id');
25
+ }
26
+
27
+ // Disable interaction
28
+ clone.style.pointerEvents = 'none';
29
+ clone.style.userSelect = 'none';
30
+
31
+ // Pause/mute media
32
+ const mediaEls = clone.querySelectorAll('audio, video');
33
+ for (let i = 0; i < mediaEls.length; i++) {
34
+ const media = mediaEls[i] as HTMLMediaElement;
35
+ media.pause?.();
36
+ media.muted = true;
37
+ }
38
+
39
+ // Mark as snapshot
40
+ clone.classList.remove('passage');
41
+ clone.classList.add('passage-snapshot');
42
+ clone.setAttribute('data-transition', transitionType);
43
+
44
+ return clone;
45
+ }
46
+
47
+ /**
48
+ * Set CSS custom properties for transition durations on the container element.
49
+ */
50
+ function setCSSProperties(el: HTMLElement, config: ResolvedTransition): void {
51
+ el.style.setProperty('--passage-in-duration', `${config.duration / 1000}s`);
52
+ el.style.setProperty('--passage-out-duration', `${config.duration / 1000}s`);
53
+ el.style.setProperty('--passage-pause', `${config.pause / 1000}s`);
54
+ }
55
+
56
+ /**
57
+ * Remove all snapshot elements and crossfading class from a container.
58
+ */
59
+ function cleanupSnapshots(containerEl: Element | null): void {
60
+ if (!containerEl) return;
61
+ containerEl.classList.remove('passage-container--crossfading');
62
+ const snapshots = containerEl.querySelectorAll('.passage-snapshot');
63
+ for (let i = 0; i < snapshots.length; i++) {
64
+ snapshots[i]!.remove();
65
+ }
66
+ }
4
67
 
5
68
  defineMacro({
6
69
  name: 'passage',
@@ -9,11 +72,188 @@ defineMacro({
9
72
  const currentPassage = useStoryStore((s) => s.currentPassage);
10
73
  const storyData = useStoryStore((s) => s.storyData);
11
74
 
12
- const passage = storyData?.passages.get(currentPassage);
13
- if (!passage) {
75
+ // Render-gating: displayedPassage controls which passage is visually shown.
76
+ // The store updates immediately, but the visual swap is deferred by the
77
+ // transition state machine.
78
+ const [displayedPassage, setDisplayedPassage] = useState(currentPassage);
79
+
80
+ // Track the resolved transition type for the data-transition prop
81
+ const resolvedTypeRef = useRef<string>('fade');
82
+
83
+ // Track previous history length to detect restart/load
84
+ const prevHistoryLenRef = useRef(0);
85
+
86
+ // Container ref for snapshot insertion
87
+ const containerRef = useRef<HTMLDivElement>(null);
88
+
89
+ // Track in-progress transition timeouts for cancellation
90
+ const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
91
+
92
+ /** Cancel any in-progress transition. */
93
+ const cancelTransition = useCallback(() => {
94
+ for (const t of timeoutsRef.current) clearTimeout(t);
95
+ timeoutsRef.current = [];
96
+ cleanupSnapshots(containerRef.current);
97
+ }, []);
98
+
99
+ // State machine effect — triggers on passage change
100
+ useEffect(() => {
101
+ const store = useStoryStore.getState();
102
+ const { history, historyIndex, transitionConfig } = store;
103
+
104
+ // Always consume the next transition (one-shot), regardless of what we do
105
+ const next = store.consumeNextTransition();
106
+
107
+ // Determine if this is a first load, restart, or save-load
108
+ const prevLen = prevHistoryLenRef.current;
109
+ prevHistoryLenRef.current = history.length;
110
+
111
+ const isFirstLoad = !displayedPassage;
112
+ const isRestart =
113
+ history.length === 1 && historyIndex === 0 && prevLen > 1;
114
+ const isLoad =
115
+ history.length > 1 &&
116
+ historyIndex === history.length - 1 &&
117
+ prevLen > 1 &&
118
+ Math.abs(history.length - prevLen) > 1;
119
+
120
+ // If this is the same passage (initial render), just set it
121
+ if (currentPassage === displayedPassage) {
122
+ // Update prevHistoryLen on initial render
123
+ return;
124
+ }
125
+
126
+ // Cancel any in-progress transition
127
+ cancelTransition();
128
+
129
+ // Get the target passage
130
+ const targetPassage = storyData?.passages.get(currentPassage);
131
+ if (!targetPassage) {
132
+ // Passage not found — show immediately
133
+ resolvedTypeRef.current = 'none';
134
+ setDisplayedPassage(currentPassage);
135
+ return;
136
+ }
137
+
138
+ // First load, restart, or load → use simple fade (no outgoing phase)
139
+ if (isFirstLoad || isRestart || isLoad) {
140
+ resolvedTypeRef.current = 'fade';
141
+ setDisplayedPassage(currentPassage);
142
+ return;
143
+ }
144
+
145
+ // Resolve transition from tags → nextTransition → store default → built-in
146
+ const config = resolveTransition(
147
+ targetPassage.tags,
148
+ next,
149
+ transitionConfig,
150
+ );
151
+
152
+ resolvedTypeRef.current = config.type;
153
+
154
+ const containerEl = containerRef.current;
155
+
156
+ // ---- State machine by type ----
157
+
158
+ if (config.type === 'none') {
159
+ // idle → mount new (no animation) → idle
160
+ setDisplayedPassage(currentPassage);
161
+ return;
162
+ }
163
+
164
+ if (config.type === 'fade') {
165
+ // idle → mount new → incoming (CSS fade in) → idle
166
+ if (containerEl) setCSSProperties(containerEl, config);
167
+ setDisplayedPassage(currentPassage);
168
+ return;
169
+ }
170
+
171
+ if (config.type === 'fade-through') {
172
+ // idle → outgoing (fade out snapshot) → paused → incoming (mount new, fade in) → idle
173
+ if (!containerEl) {
174
+ setDisplayedPassage(currentPassage);
175
+ return;
176
+ }
177
+
178
+ setCSSProperties(containerEl, config);
179
+
180
+ // Take snapshot of current passage
181
+ const passageEl = containerEl.querySelector('.passage');
182
+ if (!passageEl) {
183
+ setDisplayedPassage(currentPassage);
184
+ return;
185
+ }
186
+
187
+ const snapshot = createSnapshot(passageEl, config.type);
188
+ containerEl.appendChild(snapshot);
189
+
190
+ // Hide the current passage during outgoing phase by setting empty key
191
+ setDisplayedPassage('');
192
+
193
+ // After outgoing duration + pause, mount the new passage
194
+ const incomingDelay = config.duration + config.pause;
195
+ const t1 = setTimeout(() => {
196
+ setDisplayedPassage(currentPassage);
197
+ }, incomingDelay);
198
+
199
+ // After full transition, clean up snapshot
200
+ const cleanupDelay = incomingDelay + config.duration;
201
+ const t2 = setTimeout(() => {
202
+ cleanupSnapshots(containerEl);
203
+ }, cleanupDelay);
204
+
205
+ timeoutsRef.current = [t1, t2];
206
+ return;
207
+ }
208
+
209
+ if (config.type === 'crossfade') {
210
+ // idle → crossfading (mount new + fade in, simultaneously fade out snapshot) → idle
211
+ if (!containerEl) {
212
+ setDisplayedPassage(currentPassage);
213
+ return;
214
+ }
215
+
216
+ setCSSProperties(containerEl, config);
217
+
218
+ // Take snapshot of current passage
219
+ const passageEl = containerEl.querySelector('.passage');
220
+ if (!passageEl) {
221
+ setDisplayedPassage(currentPassage);
222
+ return;
223
+ }
224
+
225
+ const snapshot = createSnapshot(passageEl, config.type);
226
+
227
+ // Use CSS grid stacking for crossfade
228
+ containerEl.classList.add('passage-container--crossfading');
229
+ containerEl.appendChild(snapshot);
230
+
231
+ // Mount new passage immediately alongside snapshot
232
+ setDisplayedPassage(currentPassage);
233
+
234
+ // After duration, clean up snapshot and remove crossfading class
235
+ const t1 = setTimeout(() => {
236
+ cleanupSnapshots(containerEl);
237
+ }, config.duration);
238
+
239
+ timeoutsRef.current = [t1];
240
+ return;
241
+ }
242
+
243
+ // Fallback: unknown type, just mount
244
+ setDisplayedPassage(currentPassage);
245
+ // eslint-disable-next-line react-hooks/exhaustive-deps
246
+ }, [currentPassage]);
247
+
248
+ // Resolve the passage to display (or show nothing during outgoing phase)
249
+ const passage = displayedPassage
250
+ ? storyData?.passages.get(displayedPassage)
251
+ : null;
252
+
253
+ if (!passage && displayedPassage) {
14
254
  return (
15
255
  <div class="error">
16
- Error: Passage &ldquo;{currentPassage}&rdquo; not found.
256
+ Error: Passage &ldquo;{displayedPassage}&rdquo; not found.
17
257
  </div>
18
258
  );
19
259
  }
@@ -33,10 +273,18 @@ defineMacro({
33
273
  {renderPassageContent(readyPassage)}
34
274
  </div>
35
275
  )}
36
- <Passage
37
- passage={passage}
38
- key={currentPassage}
39
- />
276
+ <div
277
+ class="passage-container"
278
+ ref={containerRef}
279
+ >
280
+ {passage && (
281
+ <Passage
282
+ passage={passage}
283
+ key={displayedPassage}
284
+ dataTransition={resolvedTypeRef.current}
285
+ />
286
+ )}
287
+ </div>
40
288
  </div>
41
289
  );
42
290
  },
@@ -0,0 +1,75 @@
1
+ import { defineMacro } from '../../define-macro';
2
+ import { addTrigger, removeTrigger } from '../../triggers';
3
+ import type { WatchOptions } from '../../triggers';
4
+
5
+ defineMacro({
6
+ name: 'watch',
7
+ render(props, ctx) {
8
+ const { hooks } = ctx;
9
+
10
+ // Parse: condition string + keyword options
11
+ const raw = props.rawArgs.trim();
12
+ // First quoted string is the condition
13
+ const condMatch = raw.match(/^(['"])(.*?)\1\s*/);
14
+ if (!condMatch) return null;
15
+
16
+ const condition = condMatch[2]!;
17
+ const rest = raw.slice(condMatch[0].length);
18
+
19
+ const options: WatchOptions = {};
20
+ // Parse keyword args: goto "X", dialog "X", run "X", once, name "X", priority N
21
+ const kwRe = /(\w+)\s+(?:"([^"]*?)"|'([^']*?)'|(\d+))|(\w+)/g;
22
+ let m: RegExpExecArray | null;
23
+ while ((m = kwRe.exec(rest)) !== null) {
24
+ const key = m[1] ?? m[5];
25
+ const val = m[2] ?? m[3] ?? m[4];
26
+ if (!key) continue;
27
+ switch (key) {
28
+ case 'goto':
29
+ options.goto = val;
30
+ break;
31
+ case 'dialog':
32
+ options.dialog = val;
33
+ break;
34
+ case 'run':
35
+ options.run = val;
36
+ break;
37
+ case 'name':
38
+ options.name = val;
39
+ break;
40
+ case 'priority':
41
+ options.priority = val ? Number(val) : 0;
42
+ break;
43
+ case 'once':
44
+ options.once = true;
45
+ break;
46
+ }
47
+ }
48
+
49
+ // Register during render (like {set}) — triggers survive navigation
50
+ // and are cleaned up via resetTriggers() on restart or {unwatch}.
51
+ const registered = hooks.useRef(false);
52
+ if (!registered.current) {
53
+ registered.current = true;
54
+ addTrigger(condition, options);
55
+ }
56
+
57
+ return null;
58
+ },
59
+ });
60
+
61
+ defineMacro({
62
+ name: 'unwatch',
63
+ render(props, ctx) {
64
+ const raw = props.rawArgs.trim();
65
+ const name = raw.replace(/^['"]|['"]$/g, '');
66
+
67
+ const ran = ctx.hooks.useRef(false);
68
+ if (!ran.current) {
69
+ ran.current = true;
70
+ removeTrigger(name);
71
+ }
72
+
73
+ return null;
74
+ },
75
+ });
package/src/index.tsx CHANGED
@@ -5,6 +5,7 @@ import { useStoryStore } from './store';
5
5
  import { installStoryAPI } from './story-api';
6
6
  import { resetIdCounters } from './action-registry';
7
7
  import { executeStoryInit } from './story-init';
8
+ import { checkTriggers, reinitTriggerState } from './triggers';
8
9
  import { loadSession } from './saves/save-manager';
9
10
  import {
10
11
  parseStoryVariables,
@@ -126,6 +127,21 @@ function boot() {
126
127
  }
127
128
  });
128
129
 
130
+ // Wire up trigger system: check triggers on variable mutations, reinit on history nav/load
131
+ let prevVars = useStoryStore.getState().variables;
132
+ let prevHistoryIndex = useStoryStore.getState().historyIndex;
133
+ useStoryStore.subscribe((state) => {
134
+ if (state.variables !== prevVars) {
135
+ if (state.historyIndex === prevHistoryIndex) {
136
+ checkTriggers();
137
+ } else {
138
+ reinitTriggerState();
139
+ }
140
+ }
141
+ prevVars = state.variables;
142
+ prevHistoryIndex = state.historyIndex;
143
+ });
144
+
129
145
  // Warn if StoryInterface passage exists but doesn't contain {passage}
130
146
  const storyInterfacePassage = storyData.passages.get('StoryInterface');
131
147
  if (
@@ -31,3 +31,4 @@ import '../components/macros/Listbox';
31
31
  import '../components/macros/Cycle';
32
32
  import '../components/macros/Span';
33
33
  import '../components/macros/PassageDisplay';
34
+ import '../components/macros/Watch';