@robosystems/core 0.11.5 → 0.12.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,66 @@
1
+ export interface Rect {
2
+ x: number;
3
+ y: number;
4
+ w: number;
5
+ h: number;
6
+ }
7
+ export interface DemoTheme {
8
+ /** Selector for the sidebar that holds `.pill` and the `.nv[data-k]` items. */
9
+ side: string;
10
+ /** Resting item colour, as RGB. */
11
+ rest: [number, number, number];
12
+ /** Resting colour of an indented `.nv.sub` item; defaults to `rest`. */
13
+ restSub?: [number, number, number];
14
+ /** Colour of the item under the pill. */
15
+ active: [number, number, number];
16
+ }
17
+ export interface DemoContext {
18
+ $: (id: string) => HTMLElement;
19
+ root: ShadowRoot;
20
+ stage: HTMLElement;
21
+ rel: (el: Element) => Rect;
22
+ ring: (hl: HTMLElement, target: Element, p: number, pad?: number) => void;
23
+ nav: (k: string, from?: string, m?: number, scope?: ParentNode) => void;
24
+ }
25
+ export interface DemoLayout {
26
+ width: number;
27
+ height: number;
28
+ css?: string;
29
+ }
30
+ export interface DemoDefinition extends DemoLayout {
31
+ total: number;
32
+ poster?: number;
33
+ html: string;
34
+ setup: (ctx: DemoContext) => (t: number) => void;
35
+ mobile?: DemoLayout;
36
+ }
37
+ export interface MountOptions {
38
+ autoplay?: boolean;
39
+ phone?: boolean;
40
+ /** The app's base CSS: its tokens, window chrome and shared components. */
41
+ css?: string;
42
+ theme?: DemoTheme;
43
+ }
44
+ export interface DemoHandle {
45
+ seek: (t: number) => void;
46
+ destroy: () => void;
47
+ }
48
+ export declare const clamp01: (x: number) => number;
49
+ export declare const seg: (t: number, a: number, b: number) => number;
50
+ export declare const eo: (x: number) => number;
51
+ export declare const eio: (x: number) => number;
52
+ export declare const typed: (s: string, t: number, t0: number, cps?: number) => string;
53
+ export declare const spin: (t: number) => string;
54
+ export declare const money: (v: number, dp?: number) => string;
55
+ export declare function rise(el: HTMLElement, p: number, dy?: number): void;
56
+ export declare const dip: (t: number, at: number, d?: number) => number;
57
+ export declare function swap(el: HTMLElement, t: number, at: number, a?: string | null, b?: string | null, cls?: [string, string]): boolean;
58
+ export declare function steps(el: HTMLElement, t: number, times: number[], texts?: string[] | null, classes?: string[] | null): number;
59
+ export declare function blurIn(el: HTMLElement, p: number, dy?: number): void;
60
+ export declare function push(el: HTMLElement, t: number, total: number, ox: number, oy: number, amount?: number): void;
61
+ export declare function pointer(ctx: DemoContext, cur: HTMLElement, el: Element, t: number, t0: number, t1: number, { from, out, }?: {
62
+ from?: [number, number];
63
+ out?: number;
64
+ }): boolean;
65
+ export declare function variant(def: DemoDefinition, phone: boolean): DemoDefinition;
66
+ export declare function mount(host: HTMLElement, def: DemoDefinition, { autoplay, phone, css, theme }?: MountOptions): DemoHandle;
@@ -0,0 +1,278 @@
1
+ /*
2
+ * The runtime behind the landing-page demos in each app's /public/demos.
3
+ *
4
+ * A demo module default-exports { width, height, total, poster, css, html,
5
+ * setup(ctx), mobile? } where setup returns seek(t), a pure function of time.
6
+ * The same module runs live on the landing page (mount, in a shadow root,
7
+ * looping while visible) and frame by frame under the content machine's
8
+ * renderer (render.html), so the page and the social cut share one source.
9
+ *
10
+ * This file compiles to dependency-free ESM that a browser loads as-is: each
11
+ * app commits a copy at public/demos/runtime.js (npm run sync:demos) and a
12
+ * test there fails when the copy drifts from the installed package. The app's
13
+ * own kit.js wraps mount() with its window chrome, base CSS and sidebar theme.
14
+ */
15
+ export const clamp01 = (x) => Math.max(0, Math.min(1, x));
16
+ export const seg = (t, a, b) => clamp01((t - a) / (b - a));
17
+ export const eo = (x) => 1 - Math.pow(1 - clamp01(x), 3);
18
+ export const eio = (x) => {
19
+ x = clamp01(x);
20
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
21
+ };
22
+ export const typed = (s, t, t0, cps = 34) => s.slice(0, Math.max(0, Math.floor((t - t0) * cps)));
23
+ export const spin = (t) => '◐◓◑◒'[Math.floor(t * 8) % 4];
24
+ export const money = (v, dp = 2) => '$' +
25
+ v.toLocaleString('en-US', {
26
+ minimumFractionDigits: dp,
27
+ maximumFractionDigits: dp,
28
+ });
29
+ export function rise(el, p, dy = 40) {
30
+ el.style.opacity = String(p);
31
+ el.style.transform = `translateY(${(1 - p) * dy}px)`;
32
+ }
33
+ /* Opacity for an element whose content changes at `at`: dips through 0.15 over `d` seconds. */
34
+ export const dip = (t, at, d = 0.3) => Math.min(1, 0.15 + 0.85 * (Math.abs(t - at) / (d / 2)));
35
+ /*
36
+ * Change a label or badge without a one-frame pop: before `at` it shows `a`,
37
+ * after it `b` (text, and optional class names), with an opacity dip across
38
+ * the change. With `a` null only the dip applies, for text set elsewhere.
39
+ */
40
+ export function swap(el, t, at, a, b, cls) {
41
+ const after = t >= at;
42
+ if (a != null)
43
+ el.textContent = after ? (b !== null && b !== void 0 ? b : '') : a;
44
+ if (cls)
45
+ el.className = after ? cls[1] : cls[0];
46
+ el.style.opacity = String(dip(t, at));
47
+ return after;
48
+ }
49
+ /* A badge that moves through states at `times`: texts[k] and classes[k], dipping at each change. */
50
+ export function steps(el, t, times, texts, classes) {
51
+ let k = 0;
52
+ times.forEach((x) => (k += t >= x ? 1 : 0));
53
+ if (texts)
54
+ el.textContent = texts[k];
55
+ if (classes)
56
+ el.className = classes[k];
57
+ el.style.opacity = String(Math.min(1, ...times.map((x) => dip(t, x))));
58
+ return k;
59
+ }
60
+ /* Headline entrance: blur to sharp, rising, as p goes 0 to 1. */
61
+ export function blurIn(el, p, dy = 24) {
62
+ const e = eo(p);
63
+ el.style.opacity = String(clamp01(p * 1.4));
64
+ el.style.filter = e < 1 ? `blur(${(1 - e) * 14}px)` : '';
65
+ el.style.transform = `translateY(${(1 - e) * dy}px)`;
66
+ }
67
+ /*
68
+ * A slow camera push over the loop toward (ox, oy), in stage px, easing back
69
+ * before the loop wraps, so no screen sits still like a slide.
70
+ */
71
+ export function push(el, t, total, ox, oy, amount = 0.04) {
72
+ const k = amount *
73
+ eio(seg(t, 0.4, total - 1.2)) *
74
+ (1 - eio(seg(t, total - 1.2, total)));
75
+ el.style.transformOrigin = `${ox}px ${oy}px`;
76
+ el.style.transform = `scale(${1 + k})`;
77
+ }
78
+ /* A drawn pointer that travels from `from` to the centre of `el`, presses, and fades out. */
79
+ export function pointer(ctx, cur, el, t, t0, t1, { from = [260, 160], out = 0.6, } = {}) {
80
+ if (t < t0 - 0.15 || t > t1 + out) {
81
+ cur.style.opacity = '0';
82
+ return t > t1;
83
+ }
84
+ const r = ctx.rel(el);
85
+ const tx = r.x + r.w * 0.55;
86
+ const ty = r.y + r.h * 0.55;
87
+ const m = eio(seg(t, t0, t1));
88
+ cur.style.left = tx + (1 - m) * from[0] + 'px';
89
+ cur.style.top = ty + (1 - m) * from[1] + 'px';
90
+ cur.style.opacity = String(seg(t, t0 - 0.15, t0) * (1 - seg(t, t1 + out - 0.2, t1 + out)));
91
+ const pressed = t > t1 && t < t1 + 0.18;
92
+ cur.style.transform = `scale(${pressed ? 0.85 : 1})`;
93
+ return t > t1;
94
+ }
95
+ /* The stage's own reset; everything else, including the tokens, comes from the app. */
96
+ const STAGE_CSS = `
97
+ :host { display: block; }
98
+ .stage { position: absolute; left: 0; top: 0; transform-origin: 0 0; overflow: hidden; }
99
+ :where(.stage *) { box-sizing: border-box; margin: 0; padding: 0; }
100
+ `;
101
+ function makeCtx(root, stage, getScale, theme) {
102
+ const $ = (id) => root.getElementById(id);
103
+ const rel = (el) => {
104
+ const s = stage.getBoundingClientRect();
105
+ const r = el.getBoundingClientRect();
106
+ const k = getScale();
107
+ return {
108
+ x: (r.left - s.left) / k,
109
+ y: (r.top - s.top) / k,
110
+ w: r.width / k,
111
+ h: r.height / k,
112
+ };
113
+ };
114
+ /* ring `hl` (absolutely positioned in its offsetParent) around `target` */
115
+ const ring = (hl, target, p, pad = 6) => {
116
+ const parent = hl.offsetParent;
117
+ // a hidden scene has no layout to measure; skip rather than throw
118
+ if (!parent) {
119
+ hl.style.opacity = '0';
120
+ return;
121
+ }
122
+ const a = rel(target);
123
+ const b = rel(parent);
124
+ // a camera push scales the parent; undo it so the ring sits in its local px
125
+ const k = b.w / parent.offsetWidth || 1;
126
+ hl.style.left = (a.x - b.x) / k - pad + 'px';
127
+ hl.style.top = (a.y - b.y) / k - pad * 0.7 + 'px';
128
+ hl.style.width = a.w / k + pad * 2 + 'px';
129
+ hl.style.height = a.h / k + pad * 1.4 + 'px';
130
+ hl.style.opacity = String(p);
131
+ };
132
+ /* slide the pill of the sidebar inside `scope` to `k`, blending from `from` by m */
133
+ const nav = (k, from = k, m = 1, scope = root) => {
134
+ if (!theme)
135
+ return;
136
+ const side = scope.querySelector(theme.side);
137
+ if (!side)
138
+ return;
139
+ const item = (key) => side.querySelector(`[data-k="${key}"]`);
140
+ const y = (key) => { var _a, _b; return (_b = (_a = item(key)) === null || _a === void 0 ? void 0 : _a.offsetTop) !== null && _b !== void 0 ? _b : 0; };
141
+ const pill = side.querySelector('.pill');
142
+ if (pill)
143
+ pill.style.top = y(from) + (y(k) - y(from)) * eio(m) + 'px';
144
+ // the active item's colour blends across the move instead of switching
145
+ const w = eio(m);
146
+ side.querySelectorAll('.nv').forEach((d) => {
147
+ var _a;
148
+ const key = d.dataset.k;
149
+ const on = (key === k ? w : 0) + (key === from && from !== k ? 1 - w : 0);
150
+ const rest = d.classList.contains('sub')
151
+ ? ((_a = theme.restSub) !== null && _a !== void 0 ? _a : theme.rest)
152
+ : theme.rest;
153
+ const c = rest.map((v, i) => Math.round(v + (theme.active[i] - v) * on));
154
+ d.classList.remove('on');
155
+ d.style.color = `rgb(${c.join(',')})`;
156
+ });
157
+ };
158
+ return { $, root, stage, rel, ring, nav };
159
+ }
160
+ /* The phone layout of a demo: its `mobile` block overrides the stage size and adds CSS. */
161
+ export function variant(def, phone) {
162
+ if (!phone || !def.mobile)
163
+ return def;
164
+ return Object.assign(Object.assign(Object.assign({}, def), def.mobile), { css: (def.css || '') + (def.mobile.css || '') });
165
+ }
166
+ /* Phones: screens narrower than Tailwind's sm breakpoint get a demo's phone layout. */
167
+ const PHONE = '(max-width: 639px)';
168
+ /*
169
+ * Mount a demo into `host` (a shadow root keeps its CSS off the page). With
170
+ * autoplay it scales to the host's width, uses the phone layout on narrow
171
+ * screens, loops while on screen, and holds the poster frame for reduced
172
+ * motion; without, it sits at native size for the renderer (`phone` picks the
173
+ * phone layout there).
174
+ */
175
+ export function mount(host, def, { autoplay = true, phone = false, css = '', theme } = {}) {
176
+ var _a;
177
+ const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
178
+ let scale = 1;
179
+ let v = def;
180
+ let stage;
181
+ let seek = () => { };
182
+ const build = (isPhone) => {
183
+ v = variant(def, isPhone);
184
+ root.innerHTML = `<style>${STAGE_CSS}${css}${v.css || ''}</style>
185
+ <div class="stage" style="width:${v.width}px;height:${v.height}px">${v.html}</div>`;
186
+ stage = root.querySelector('.stage');
187
+ const ctx = makeCtx(root, stage, () => scale, theme);
188
+ // each window's pill starts under the item its chrome marked active, so a
189
+ // demo that never calls nav() does not show it on the first item
190
+ if (theme)
191
+ root.querySelectorAll(theme.side).forEach((side) => {
192
+ const on = side.querySelector('.nv.on');
193
+ if ((on === null || on === void 0 ? void 0 : on.dataset.k) && side.parentNode)
194
+ ctx.nav(on.dataset.k, on.dataset.k, 1, side.parentNode);
195
+ });
196
+ const pose = v.setup(ctx);
197
+ // [data-loop] content fades in at the start of the loop and out at its end,
198
+ // so the wrap back to the first frame is a dissolve, not a jump.
199
+ const looped = [...root.querySelectorAll('[data-loop]')];
200
+ const total = v.total;
201
+ seek = (t) => {
202
+ pose(t);
203
+ const f = Math.min(seg(t, 0, 0.35), 1 - seg(t, total - 0.35, total));
204
+ looped.forEach((el) => (el.style.opacity = String(f)));
205
+ };
206
+ };
207
+ const poster = (_a = def.poster) !== null && _a !== void 0 ? _a : 0;
208
+ if (!autoplay) {
209
+ build(phone);
210
+ seek(poster);
211
+ return { seek: (t) => seek(t), destroy() { } };
212
+ }
213
+ const phoneQuery = matchMedia(PHONE);
214
+ const fit = () => {
215
+ scale = host.clientWidth / v.width || 1;
216
+ stage.style.transform = `scale(${scale})`;
217
+ };
218
+ build(phoneQuery.matches);
219
+ fit();
220
+ const ro = new ResizeObserver(fit);
221
+ ro.observe(host);
222
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)');
223
+ let t = poster;
224
+ let started = false;
225
+ let last = null;
226
+ let raf = 0;
227
+ let visible = false;
228
+ seek(t);
229
+ const loop = (now) => {
230
+ if (last != null)
231
+ t = (t + Math.min(0.1, (now - last) / 1000)) % def.total;
232
+ last = now;
233
+ seek(t);
234
+ raf = requestAnimationFrame(loop);
235
+ };
236
+ const update = () => {
237
+ cancelAnimationFrame(raf);
238
+ last = null;
239
+ if (reduce.matches) {
240
+ t = poster;
241
+ seek(t);
242
+ return;
243
+ }
244
+ if (visible && !document.hidden) {
245
+ if (!started) {
246
+ started = true;
247
+ t = 0;
248
+ }
249
+ raf = requestAnimationFrame(loop);
250
+ }
251
+ else
252
+ seek(t);
253
+ };
254
+ const relayout = () => {
255
+ build(phoneQuery.matches);
256
+ fit();
257
+ update();
258
+ };
259
+ const io = new IntersectionObserver(([e]) => {
260
+ visible = e.isIntersecting;
261
+ update();
262
+ }, { threshold: 0.2 });
263
+ io.observe(host);
264
+ document.addEventListener('visibilitychange', update);
265
+ reduce.addEventListener('change', update);
266
+ phoneQuery.addEventListener('change', relayout);
267
+ return {
268
+ seek: (x) => seek(x),
269
+ destroy() {
270
+ cancelAnimationFrame(raf);
271
+ io.disconnect();
272
+ ro.disconnect();
273
+ document.removeEventListener('visibilitychange', update);
274
+ reduce.removeEventListener('change', update);
275
+ phoneQuery.removeEventListener('change', relayout);
276
+ },
277
+ };
278
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robosystems/core",
3
- "version": "0.11.5",
3
+ "version": "0.12.0",
4
4
  "description": "Shared RoboSystems frontend core library (auth, contexts, task monitoring, UI components) for the RoboSystems Next.js apps",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,22 @@
1
+ export interface LiveDemoProps {
2
+ /** Demo module name under /public/demos (e.g. "hero", "inbox"). */
3
+ name: string;
4
+ /** Stage aspect ratio as width / height, reserved before the demo loads. */
5
+ aspect: number;
6
+ /**
7
+ * The phone layout's aspect ratio (the demo module's `mobile` width / height),
8
+ * used below the sm breakpoint, where kit.js mounts that layout.
9
+ */
10
+ phoneAspect?: number;
11
+ /** What the animation shows, for screen readers. */
12
+ label: string;
13
+ className?: string;
14
+ }
15
+ /**
16
+ * An animated product demo from the app's /public/demos, mounted in a shadow root so its
17
+ * styles stay off the page. The demos are plain ES modules shared with the
18
+ * content machine's renderer, which shoots the same files for the social cuts;
19
+ * see demos/runtime.ts in this package. They loop only while on screen and hold one frame
20
+ * for reduced motion.
21
+ */
22
+ export declare function LiveDemo({ name, aspect, label, phoneAspect, className, }: LiveDemoProps): import("react").JSX.Element;
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef } from 'react';
4
+ /**
5
+ * An animated product demo from the app's /public/demos, mounted in a shadow root so its
6
+ * styles stay off the page. The demos are plain ES modules shared with the
7
+ * content machine's renderer, which shoots the same files for the social cuts;
8
+ * see demos/runtime.ts in this package. They loop only while on screen and hold one frame
9
+ * for reduced motion.
10
+ */
11
+ export function LiveDemo({ name, aspect, label, phoneAspect, className = '', }) {
12
+ const host = useRef(null);
13
+ useEffect(() => {
14
+ let handle;
15
+ let cancelled = false;
16
+ const load = async () => {
17
+ // Served from /public at runtime, never bundled.
18
+ const base = '/demos/';
19
+ const [kit, demo] = await Promise.all([
20
+ import(
21
+ /* webpackIgnore: true */ /* turbopackIgnore: true */ base + 'kit.js'),
22
+ import(
23
+ /* webpackIgnore: true */ /* turbopackIgnore: true */ base +
24
+ name +
25
+ '.js'),
26
+ ]);
27
+ if (cancelled || !host.current)
28
+ return;
29
+ handle = kit.mount(host.current, demo.default);
30
+ };
31
+ load().catch((err) => {
32
+ // The page stands without the demo, but a broken module should still show up.
33
+ console.error(`LiveDemo "${name}" failed to load`, err);
34
+ });
35
+ return () => {
36
+ cancelled = true;
37
+ handle === null || handle === void 0 ? void 0 : handle.destroy();
38
+ };
39
+ }, [name]);
40
+ return (_jsx("div", { ref: host, role: "img", "aria-label": label, className: `relative aspect-(--aspect) w-full overflow-hidden max-sm:aspect-(--phone-aspect) ${className}`, style: {
41
+ '--aspect': aspect,
42
+ '--phone-aspect': phoneAspect !== null && phoneAspect !== void 0 ? phoneAspect : aspect,
43
+ } }));
44
+ }
@@ -2,6 +2,7 @@ export { ACCOUNT_SETTINGS_PATH, useAccountSettingsLink, type AccountSettingsLink
2
2
  export { CoreNavbar } from './CoreNavbar';
3
3
  export { CoreSidebar, type SidebarItemData } from './CoreSidebar';
4
4
  export { LandingFooter, type FooterLink, type LandingFooterProps, } from './LandingFooter';
5
+ export { LiveDemo, type LiveDemoProps } from './LiveDemo';
5
6
  export { PageContainer } from './PageContainer';
6
7
  export { PageHeader, type PageHeaderProps } from './PageHeader';
7
8
  export { SettingsContainer } from './SettingsContainer';
@@ -2,6 +2,7 @@ export { ACCOUNT_SETTINGS_PATH, useAccountSettingsLink, } from './account-settin
2
2
  export { CoreNavbar } from './CoreNavbar';
3
3
  export { CoreSidebar } from './CoreSidebar';
4
4
  export { LandingFooter, } from './LandingFooter';
5
+ export { LiveDemo } from './LiveDemo';
5
6
  export { PageContainer } from './PageContainer';
6
7
  export { PageHeader } from './PageHeader';
7
8
  export { SettingsContainer } from './SettingsContainer';