@octanejs/thinking-orbs 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.
package/src/presets.ts ADDED
@@ -0,0 +1,106 @@
1
+ // The shipped tunings: nine states × two sizes, baked from the inkform
2
+ // mini-page tuning session. `count`/`size` are multipliers over the base
3
+ // fine profiles; `speed` multiplies the shared clock. Resolved once per
4
+ // (state, size) pair and cached — the render loop sees plain numbers.
5
+
6
+ import type { ModeOpts } from './engine/profiles';
7
+ import { BASE_PROFILES, scaleCounts, scaleRadii } from './engine/profiles';
8
+ import type { OrbSize, OrbState } from './types';
9
+
10
+ export type ModeKey =
11
+ 'orbits' | 'globe' | 'rubik' | 'wave' | 'web' | 'braid' | 'ribbon' | 'ring' | 'morph';
12
+
13
+ export const STATE_TO_MODE: Record<OrbState, ModeKey> = {
14
+ working: 'orbits',
15
+ searching: 'globe',
16
+ solving: 'rubik',
17
+ listening: 'wave',
18
+ connecting: 'web',
19
+ weaving: 'braid',
20
+ composing: 'ribbon',
21
+ breathing: 'ring',
22
+ shaping: 'morph',
23
+ };
24
+
25
+ interface Preset {
26
+ speed: number;
27
+ count: number;
28
+ size: number;
29
+ /** Extra mode opts merged verbatim after scaling. */
30
+ extra?: ModeOpts;
31
+ }
32
+
33
+ const PRESETS: Record<ModeKey, Record<OrbSize, Preset>> = {
34
+ orbits: {
35
+ 64: { speed: 1.885, count: 1, size: 1 },
36
+ 20: { speed: 3.9, count: 0.238, size: 2.4 },
37
+ },
38
+ globe: {
39
+ 64: { speed: 2.015, count: 0.42, size: 1.15, extra: { scanMul: 4.08, dimBase: 0.45 } },
40
+ 20: { speed: 2.665, count: 0.105, size: 1.75, extra: { scanMul: 4.335, dimBase: 0.45 } },
41
+ },
42
+ rubik: {
43
+ 64: { speed: 1.82, count: 0.35, size: 1.05 },
44
+ 20: { speed: 1.95, count: 0.088, size: 1.9 },
45
+ },
46
+ wave: {
47
+ 64: { speed: 4.388, count: 0.341, size: 1 },
48
+ 20: { speed: 3.998, count: 0.105, size: 1.6 },
49
+ },
50
+ web: {
51
+ 64: { speed: 3.315, count: 1.35, size: 0.95 },
52
+ 20: { speed: 6.63, count: 0.25, size: 1.52 },
53
+ },
54
+ braid: {
55
+ 64: { speed: 1.625, count: 0.5, size: 1 },
56
+ 20: { speed: 2.75, count: 0.1125, size: 1.36 },
57
+ },
58
+ ribbon: {
59
+ 64: { speed: 2.34, count: 0.25, size: 0.85, extra: { spin: 0, bandMul: 3.9, wobMul: 1 } },
60
+ 20: { speed: 3.12, count: 0.051, size: 1.073, extra: { spin: 0, bandMul: 4.94, wobMul: 1 } },
61
+ },
62
+ ring: {
63
+ 64: {
64
+ speed: 3.24,
65
+ count: 0.25,
66
+ size: 0.956,
67
+ extra: { spin: 0, bandMul: 3.627, wobMul: 0.368 },
68
+ },
69
+ 20: {
70
+ speed: 3.78,
71
+ count: 0.028,
72
+ size: 1.622,
73
+ extra: { spin: 0, bandMul: 3.968, wobMul: 0.565 },
74
+ },
75
+ },
76
+ morph: {
77
+ 64: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } },
78
+ 20: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } },
79
+ },
80
+ };
81
+
82
+ export interface Resolved {
83
+ mode: ModeKey;
84
+ speed: number;
85
+ opts: ModeOpts;
86
+ }
87
+
88
+ const cache = new Map<string, Resolved>();
89
+
90
+ /** Resolve a (state, size) pair to its mode + fully-scaled draw options. */
91
+ export function resolvePreset(state: OrbState, size: OrbSize): Resolved {
92
+ const key = `${state}-${size}`;
93
+ const hit = cache.get(key);
94
+ if (hit) return hit;
95
+
96
+ const mode = STATE_TO_MODE[state];
97
+ const preset = PRESETS[mode][size];
98
+ let opts: ModeOpts = { ...BASE_PROFILES[mode] };
99
+ if (preset.count !== 1) opts = scaleCounts(opts, preset.count);
100
+ if (preset.size !== 1) opts = scaleRadii(opts, preset.size);
101
+ if (preset.extra) opts = { ...opts, ...preset.extra };
102
+
103
+ const resolved: Resolved = { mode, speed: preset.speed, opts };
104
+ cache.set(key, resolved);
105
+ return resolved;
106
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,83 @@
1
+ import { useEffect, useState } from 'octane';
2
+ import type { OrbTheme } from './types';
3
+
4
+ type RefObject<T> = { current: T | null };
5
+
6
+ function ancestorTheme(el: Element | null): boolean | null {
7
+ let node: Element | null = el;
8
+ while (node) {
9
+ const attr = node.getAttribute('data-theme');
10
+ if (attr === 'dark') return true;
11
+ if (attr === 'light') return false;
12
+ if (node.classList.contains('dark')) return true;
13
+ if (node.classList.contains('light')) return false;
14
+ node = node.parentElement;
15
+ }
16
+ return null;
17
+ }
18
+
19
+ function systemDark(): boolean {
20
+ return typeof matchMedia === 'undefined' || matchMedia('(prefers-color-scheme: dark)').matches;
21
+ }
22
+
23
+ export function useResolvedDark(theme: OrbTheme, hostRef: RefObject<Element | null>): boolean {
24
+ const [dark, setDark] = useState(true);
25
+
26
+ useEffect(() => {
27
+ if (theme === 'dark') {
28
+ setDark(true);
29
+ return;
30
+ }
31
+ if (theme === 'light') {
32
+ setDark(false);
33
+ return;
34
+ }
35
+
36
+ function resolve(): void {
37
+ const fromTree = ancestorTheme(hostRef.current);
38
+ setDark(fromTree ?? systemDark());
39
+ }
40
+ resolve();
41
+
42
+ const mq =
43
+ typeof matchMedia !== 'undefined' ? matchMedia('(prefers-color-scheme: dark)') : null;
44
+ const onMq = (): void => {
45
+ resolve();
46
+ };
47
+ mq?.addEventListener('change', onMq);
48
+
49
+ let mo: MutationObserver | null = null;
50
+ if (typeof MutationObserver !== 'undefined' && hostRef.current) {
51
+ mo = new MutationObserver(resolve);
52
+ mo.observe(document.documentElement, {
53
+ attributes: true,
54
+ attributeFilter: ['class', 'data-theme'],
55
+ subtree: true,
56
+ });
57
+ }
58
+
59
+ return () => {
60
+ mq?.removeEventListener('change', onMq);
61
+ mo?.disconnect();
62
+ };
63
+ }, [theme, hostRef]);
64
+
65
+ return dark;
66
+ }
67
+
68
+ export function useReducedMotion(): boolean {
69
+ const [reduced, setReduced] = useState(false);
70
+ useEffect(() => {
71
+ if (typeof matchMedia === 'undefined') return;
72
+ const mq = matchMedia('(prefers-reduced-motion: reduce)');
73
+ setReduced(mq.matches);
74
+ const on = (event: MediaQueryListEvent): void => {
75
+ setReduced(event.matches);
76
+ };
77
+ mq.addEventListener('change', on);
78
+ return () => {
79
+ mq.removeEventListener('change', on);
80
+ };
81
+ }, []);
82
+ return reduced;
83
+ }
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { Octane } from 'octane/jsx-runtime';
2
+
3
+ export type CSSProperties = Exclude<
4
+ Octane.JSX.IntrinsicElements['canvas']['style'],
5
+ string | undefined
6
+ >;
7
+
8
+ export type OrbState =
9
+ | 'working'
10
+ | 'searching'
11
+ | 'solving'
12
+ | 'listening'
13
+ | 'connecting'
14
+ | 'weaving'
15
+ | 'composing'
16
+ | 'breathing'
17
+ | 'shaping';
18
+
19
+ export type OrbSize = 64 | 20;
20
+
21
+ export type OrbTheme = 'auto' | 'dark' | 'light';
22
+
23
+ export interface ThinkingOrbProps extends Omit<
24
+ Octane.JSX.IntrinsicElements['canvas'],
25
+ 'style' | 'ref'
26
+ > {
27
+ state?: OrbState;
28
+ size?: OrbSize;
29
+ theme?: OrbTheme;
30
+ speed?: number;
31
+ paused?: boolean;
32
+ style?: CSSProperties;
33
+ ref?: Octane.JSX.IntrinsicElements['canvas']['ref'];
34
+ }