@usemote/media 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 George Heavenson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # @usemote/media
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Window frames and media particles for [Mote](https://mote.hearvie.dev) — photos,
4
+ GIFs and video that float with the shapes. **5.1 kB** brotlied.
5
+
6
+ ```bash
7
+ pnpm add @usemote/core @usemote/media
8
+ ```
9
+
10
+ ```tsx
11
+ import { FloatingField } from '@usemote/react';
12
+ import { windowShape } from '@usemote/media';
13
+ import { esports } from '@usemote/pack-esports';
14
+
15
+ <section style={{ position: 'relative', overflow: 'hidden' }}>
16
+ <FloatingField
17
+ packs={[esports]} mode="bob" count={9} avoid="center" depth
18
+ media={[
19
+ windowShape({ id: 'nova', kind: 'caption', src: '/nova.jpg', place: 'right' }),
20
+ windowShape({ id: 'demo', kind: 'browser', src: '/demo.webm',
21
+ poster: '/demo.jpg', clip: true, place: 'left' }),
22
+ ]}
23
+ />
24
+ <h1 style={{ position: 'relative' }}>Your headline</h1>
25
+ </section>
26
+ ```
27
+
28
+ ## A particle doesn't have to be a shape
29
+
30
+ Mote's renderer only ever assigns a shape's markup as the particle's contents,
31
+ so a framed photo participates in the field exactly like a drawn shape: same
32
+ layout, same depth, same motion presets, same scheduler.
33
+
34
+ ## `media`, not `packs`
35
+
36
+ Media is **singular** — you want *this* portrait, once, on the right. Shapes in
37
+ a pack are drawn from a pool, where the seed decides how often each appears or
38
+ whether it appears at all. So media is passed as `media={[…]}`: each item is
39
+ rendered exactly once, in addition to the `count` shapes.
40
+
41
+ ## `place`, not `avoid`
42
+
43
+ `avoid` is rejection-based and assumes shapes small enough to tuck around a
44
+ region. Media is large: a 280px portrait on a 700px hero has *no* valid
45
+ position, so avoidance simply fails. Media is anchored instead —
46
+ `left` `right` `top` `bottom` `top-left` `top-right` `bottom-left`
47
+ `bottom-right` `center` — with a margin kept from the field edge so a frame
48
+ reads as placed rather than clipped.
49
+
50
+ ## 13 frame kinds
51
+
52
+ `browser` `app` `bare` `caption` `phone` `monitor` `polaroid` `stream`
53
+ `post` `circle` `message` `slide` `ticket`
54
+
55
+ Each takes two colours from the active pack theme, so frames inherit whatever
56
+ pack they sit in.
57
+
58
+ ### Chrome drops detail, it doesn't shrink
59
+
60
+ A frame in a field can be any size, and chrome that merely scales turns to
61
+ mush. Each tier removes detail instead:
62
+
63
+ | Tier | Width | Behaviour |
64
+ |---|---|---|
65
+ | Small | `<270px` | Controls merge into one capsule. No text, no side rail. |
66
+ | Medium | `270–339` | Three tonal dots, title as a bar, empty URL pill. |
67
+ | Large | `340+` | Real URL and title text, play chip on clips. |
68
+
69
+ Frames build at the particle's **actual** width, so the tier is always right.
70
+
71
+ ## Video, poster and reduced motion
72
+
73
+ A video is emitted **without an `autoplay` attribute**, deliberately. Markup
74
+ must be byte-identical on the server and the client or hydration mismatches,
75
+ so playback is a runtime decision made by the renderer:
76
+
77
+ - `prefers-reduced-motion: reduce` → never started. The poster frame is what
78
+ the user sees, which is why **`poster` is strongly recommended** — a paused
79
+ video without one is a blank rectangle.
80
+ - Offscreen (via `IntersectionObserver`) → paused.
81
+ - Field paused or destroyed → paused, and decoders released.
82
+ - A refused autoplay is swallowed; the poster is a fine outcome.
83
+
84
+ Video is `muted`, `loop`, `playsinline`, `preload="metadata"`, and kept out of
85
+ the tab order.
86
+
87
+ ## Frames are scene objects, not UI
88
+
89
+ No hover states, no buttons, nothing focusable or clickable. The layer is
90
+ `aria-hidden` and `pointer-events: none`. Images are `loading="lazy"` and
91
+ decorative by default; pass `alt` if an image carries meaning.
92
+
93
+ ## Placeholders
94
+
95
+ A frame with no `src` renders hatching, optionally with a shape from the pack
96
+ via `art`, so an empty frame still looks intentional rather than broken.
97
+
98
+ ## Known limit
99
+
100
+ Frames are HTML, not SVG, so they render through the DOM renderer only.
101
+ `renderFieldSVG()` — the static export for OG images — cannot include them.
102
+
103
+ ## Inspiration
104
+
105
+ Architecturally modelled on **[Navii](https://github.com/uxderrick/navii)** by
106
+ **[Derrick Tsorme](https://github.com/uxderrick)**. The frame designs are by
107
+ Mote's own designer.
108
+
109
+ ## License
110
+
111
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,366 @@
1
+ 'use strict';
2
+
3
+ // src/copy.ts
4
+ var COPY = {
5
+ esports: {
6
+ url: "publication.gg/live",
7
+ title: "Grand Final \xB7 Lagos",
8
+ stream: ["K9 vs NOVA", "2\u20131"],
9
+ caps: [["K9", "ACCRA"], ["NOVA", "LAGOS"], ["ADA", "ACCRA"], ["DEMZY", "LAGOS"]]
10
+ },
11
+ devtools: {
12
+ url: "localhost:3000",
13
+ title: "deploy \u2014 main",
14
+ caps: [["v2.4.0", "SHIPPED"], ["main", "GREEN"], ["#128", "MERGED"], ["api", "200 OK"]]
15
+ },
16
+ ai: {
17
+ url: "chat.model.ai",
18
+ title: "Inference",
19
+ caps: [["Prompt", "TEXT"], ["Vision", "IMAGE"], ["Voice", "AUDIO"], ["Agent", "RUN"]]
20
+ },
21
+ fintech: {
22
+ url: "app.ledger.io",
23
+ title: "Portfolio",
24
+ caps: [["+2.4%", "TODAY"], ["Savings", "GOAL"], ["Card", "ACTIVE"], ["NGN/USD", "LIVE"]]
25
+ },
26
+ creative: {
27
+ url: "studio.work",
28
+ title: "Moodboard",
29
+ caps: [["Brand", "V3"], ["Poster", "DRAFT"], ["Type", "SPEC"], ["Motion", "WIP"]]
30
+ },
31
+ space: {
32
+ url: "mission.live",
33
+ title: "Telemetry",
34
+ caps: [["Orbit 3", "LIVE"], ["Mars", "SOL 214"], ["Launch", "T-10"], ["Lunar", "PASS"]]
35
+ },
36
+ cyber: {
37
+ url: "vault.secure",
38
+ title: "audit.log",
39
+ caps: [["Firewall", "ON"], ["2FA", "VERIFIED"], ["Scan", "CLEAN"], ["Vault", "LOCKED"]]
40
+ },
41
+ health: {
42
+ url: "care.app",
43
+ title: "Vitals",
44
+ caps: [["72 BPM", "REST"], ["Sleep", "7H 40"], ["Steps", "9,420"], ["Dose", "08:00"]]
45
+ },
46
+ education: {
47
+ url: "learn.school",
48
+ title: "Lesson 04",
49
+ caps: [["Algebra", "UNIT 2"], ["Physics", "LAB"], ["History", "QUIZ"], ["Art", "STUDIO"]]
50
+ }
51
+ };
52
+ var fallbackCopy = COPY.esports;
53
+
54
+ // src/util.ts
55
+ var R = Math.round;
56
+ var clamp = (v, a, b) => Math.max(a, Math.min(b, v));
57
+ var hexA = (hex, a) => /^#[0-9a-f]{6}$/i.test(hex) ? hex + R(a * 255).toString(16).padStart(2, "0") : hex;
58
+ var FONT = "'Schibsted Grotesk',system-ui,-apple-system,sans-serif";
59
+ var MONO = "'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace";
60
+ var tier = (w) => w >= 340 ? "L" : w >= 270 ? "M" : "S";
61
+ var lock = (s, col) => `<svg width="${s}" height="${R(s * 1.2)}" viewBox="0 0 10 12" style="flex:none;display:block"><path d="M2.5 5.5V3.5a2.5 2.5 0 0 1 5 0v2" fill="none" stroke="${col}" stroke-width="1.6"/><rect x="1" y="5" width="8" height="6.5" rx="1.6" fill="${col}"/></svg>`;
62
+ var lights = (T, dot, gap, col) => T === "S" ? `<span style="display:block;width:${dot * 3 + gap * 2}px;height:${dot}px;border-radius:${dot}px;background:${col};opacity:.55"></span>` : [1, 0.6, 0.35].map(
63
+ (o) => `<span style="display:block;flex:none;width:${dot}px;height:${dot}px;border-radius:50%;background:${col};opacity:${o}"></span>`
64
+ ).join("");
65
+
66
+ // src/media.ts
67
+ var VIDEO_EXT = /\.(mp4|webm|mov|m4v)(\?|#|$)/i;
68
+ var isVideo = (src) => VIDEO_EXT.test(src);
69
+ var attr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
70
+ function mediaSurface(o, mw, mh, rad) {
71
+ const c = o.c;
72
+ let inner;
73
+ if (o.src && isVideo(o.src)) {
74
+ inner = `<video data-mote-media src="${attr(o.src)}"` + (o.poster ? ` poster="${attr(o.poster)}"` : "") + ` muted loop playsinline preload="metadata" tabindex="-1" aria-hidden="true" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block"></video>`;
75
+ } else if (o.src) {
76
+ inner = `<img src="${attr(o.src)}" alt="${attr(o.alt ?? "")}" loading="lazy" decoding="async" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block">`;
77
+ } else {
78
+ const s = R(Math.min(mw, mh) * 0.46);
79
+ inner = `<div style="position:absolute;inset:0;background:${c.d};background-image:repeating-linear-gradient(135deg,${hexA(c.f, 0.08)} 0 10px,transparent 10px 20px)"></div>` + (o.art ? `<div style="position:absolute;left:50%;top:50%;width:${s}px;height:${s}px;margin:${-s / 2}px 0 0 ${-s / 2}px">${o.art}</div>` : "");
80
+ }
81
+ if (o.clip) {
82
+ const bh = Math.max(4, R(mw * 0.014));
83
+ const p = Math.max(8, R(mw * 0.045));
84
+ inner += `<div style="position:absolute;left:${p}px;right:${p}px;bottom:${p}px;height:${bh}px;border-radius:${bh}px;background:${hexA(c.f, 0.35)}"><div style="width:38%;height:100%;border-radius:${bh}px;background:${c.f}"></div></div>`;
85
+ if (mw >= 300) {
86
+ const fs = clamp(R(mw * 0.032), 10, 12);
87
+ inner += `<div style="position:absolute;left:${p}px;top:${p}px;display:flex;align-items:center;gap:6px;background:${c.f};color:${c.d};border-radius:4px;padding:4px 7px;font:500 ${fs}px/1 ${MONO};letter-spacing:.08em"><span style="width:0;height:0;border-left:${R(fs * 0.7)}px solid ${c.d};border-top:${R(fs * 0.42)}px solid transparent;border-bottom:${R(fs * 0.42)}px solid transparent"></span>0:24</div>`;
88
+ }
89
+ }
90
+ return `<div style="position:relative;width:${mw}px;height:${mh}px;overflow:hidden;border-radius:${rad};background:${c.d}">${inner}</div>`;
91
+ }
92
+
93
+ // src/frames.ts
94
+ var FORCE = {
95
+ phone: [9, 19.5],
96
+ circle: [1, 1],
97
+ slide: [4, 3],
98
+ polaroid: [1, 1]
99
+ };
100
+ var SCALE = {
101
+ phone: 0.5,
102
+ circle: 0.62,
103
+ slide: 0.72,
104
+ polaroid: 0.78,
105
+ message: 0.85,
106
+ post: 0.82,
107
+ ticket: 1.1,
108
+ monitor: 1.05
109
+ };
110
+ var KINDS = [
111
+ "browser",
112
+ "app",
113
+ "bare",
114
+ "caption",
115
+ "phone",
116
+ "monitor",
117
+ "polaroid",
118
+ "stream",
119
+ "post",
120
+ "circle",
121
+ "message",
122
+ "slide",
123
+ "ticket"
124
+ ];
125
+ var BUILDERS = {
126
+ browser(o, k) {
127
+ const { c, w, T, ratio, copy, rr, shadow } = k;
128
+ const rim = R(clamp(w * 0.012, 3, 5));
129
+ const bar = R(clamp(w * 0.08, 20, 30));
130
+ const dot = R(clamp(bar * 0.34, 7, 10));
131
+ const gap = R(dot * 0.6);
132
+ const pad = R(bar * 0.45);
133
+ const mw = w - rim * 2;
134
+ const mh = R(mw * ratio[1] / ratio[0]);
135
+ const h = bar + mh + rim;
136
+ const lw = dot * 3 + gap * 2;
137
+ const ph = R(bar * 0.52);
138
+ const url = T === "L" ? `${lock(R(ph * 0.42), c.d)}<span style="font:500 ${clamp(R(bar * 0.36), 9, 11)}px/1 ${MONO};color:${c.d};opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${copy.url}</span>` : "";
139
+ const html = `<div style="width:${w}px;height:${h}px;display:flex;flex-direction:column;border-radius:${rr}px;background:${c.f};box-shadow:${shadow};overflow:hidden"><div style="height:${bar}px;flex:none;display:grid;grid-template-columns:${lw}px minmax(0,1fr) ${lw}px;align-items:center;gap:${pad}px;padding:0 ${pad}px"><div style="display:flex;gap:${gap}px;align-items:center">${lights(T, dot, gap, c.d)}</div><div style="display:flex;justify-content:center;min-width:0"><div style="height:${ph}px;width:${T === "S" ? "70%" : "86%"};border-radius:${ph}px;background:${hexA(c.d, 0.16)};display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;box-sizing:border-box;min-width:0">${url}</div></div><div></div></div><div style="padding:0 ${rim}px ${rim}px">${mediaSurface(o, mw, mh, `0 0 ${Math.max(2, rr - rim)}px ${Math.max(2, rr - rim)}px`)}</div></div>`;
140
+ return { html, h };
141
+ },
142
+ app(o, k) {
143
+ const { c, w, T, ratio, copy, font, rr, shadow } = k;
144
+ const rim = R(clamp(w * 0.012, 3, 5));
145
+ const bar = R(clamp(w * 0.095, 22, 34));
146
+ const dot = R(clamp(bar * 0.3, 7, 10));
147
+ const gap = R(dot * 0.6);
148
+ const pad = R(bar * 0.42);
149
+ const rail = T === "S" ? 0 : R(clamp(w * 0.075, 20, 30));
150
+ const foot = R(clamp(w * 0.035, 8, 12));
151
+ const mw = w - rim - (rail || rim);
152
+ const mh = R(mw * ratio[1] / ratio[0]);
153
+ const h = bar + mh + foot;
154
+ const lw = dot * 3 + gap * 2;
155
+ const title = T === "L" ? `<span style="font:700 ${clamp(R(bar * 0.38), 10, 13)}px/1 ${font};color:${c.d};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${copy.title}</span>` : T === "M" ? `<span style="display:block;width:${R(w * 0.22)}px;height:${R(dot * 0.8)}px;border-radius:${dot}px;background:${c.d};opacity:.35"></span>` : "";
156
+ const right = T === "S" ? "" : [0, 1].map(
157
+ () => `<span style="display:block;width:${dot}px;height:${dot}px;border-radius:2px;background:${c.d};opacity:.3"></span>`
158
+ ).join("");
159
+ const ic = R(rail * 0.46);
160
+ const railHtml = rail ? `<div style="width:${rail}px;flex:none;display:flex;flex-direction:column;align-items:center;gap:${R(rail * 0.3)}px;padding-top:${R(rail * 0.15)}px">` + [0.6, 0.28, 0.28].map(
161
+ (op) => `<span style="display:block;width:${ic}px;height:${ic}px;border-radius:${R(ic * 0.28)}px;background:${c.d};opacity:${op}"></span>`
162
+ ).join("") + `</div>` : "";
163
+ const html = `<div style="width:${w}px;height:${h}px;display:flex;flex-direction:column;border-radius:${rr}px;background:${c.f};box-shadow:${shadow};overflow:hidden"><div style="height:${bar}px;flex:none;display:grid;grid-template-columns:${lw}px minmax(0,1fr) ${lw}px;align-items:center;gap:${pad}px;padding:0 ${pad}px"><div style="display:flex;gap:${gap}px;align-items:center">${lights(T, dot, gap, c.d)}</div><div style="display:flex;justify-content:center;min-width:0">${title}</div><div style="display:flex;gap:${gap}px;justify-content:flex-end">${right}</div></div><div style="display:flex;padding:0 ${rim}px 0 ${rail ? 0 : rim}px">${railHtml}${mediaSurface(o, mw, mh, "4px")}</div><div style="height:${foot}px;flex:none"></div></div>`;
164
+ return { html, h };
165
+ },
166
+ caption(o, k) {
167
+ const { c, w, T, ratio, copy, font, rr, shadow, ring } = k;
168
+ const strip = R(clamp(w * 0.13, 30, 46));
169
+ const mh = R(w * ratio[1] / ratio[0]);
170
+ const h = mh + strip;
171
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length];
172
+ const html = `<div style="width:${w}px;height:${h}px;border-radius:${rr}px;box-shadow:0 0 0 ${ring}px ${c.f},${shadow};overflow:hidden;display:flex;flex-direction:column;background:${c.f}">${mediaSurface(o, w, mh, "0")}<div style="height:${strip}px;flex:none;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 ${R(strip * 0.38)}px;color:${c.d}"><span style="font:800 ${R(strip * 0.42)}px/1 ${font};letter-spacing:-0.01em;white-space:nowrap">${cap[0]}</span>` + (T !== "S" ? `<span style="font:500 ${clamp(R(strip * 0.27), 9, 11)}px/1 ${MONO};letter-spacing:.12em;opacity:.7;white-space:nowrap">${cap[1]}</span>` : "") + `</div></div>`;
173
+ return { html, h };
174
+ },
175
+ phone(o, k) {
176
+ const { c, w, shadow } = k;
177
+ const b = R(clamp(w * 0.04, 5, 9));
178
+ const rad = R(w * 0.17);
179
+ const mw = w - b * 2;
180
+ const mh = R(mw * 19.5 / 9);
181
+ const h = mh + b * 2;
182
+ const iw = R(w * 0.3);
183
+ const ih = R(clamp(w * 0.07, 8, 16));
184
+ const hb = R(w * 0.34);
185
+ const btn = (side, top, hh) => `<span style="position:absolute;${side}:-3px;top:${R(h * top)}px;width:3px;height:${R(h * hh)}px;border-radius:2px;background:${c.f}"></span>`;
186
+ const html = `<div style="position:relative;width:${w}px;height:${h}px">${btn("left", 0.18, 0.07)}${btn("left", 0.28, 0.11)}${btn("right", 0.24, 0.13)}<div style="position:relative;width:${w}px;height:${h}px;border-radius:${rad}px;background:${c.f};padding:${b}px;box-sizing:border-box;box-shadow:${shadow}">${mediaSurface(o, mw, mh, `${rad - b}px`)}<span style="position:absolute;left:50%;top:${b + R(ih * 0.6)}px;width:${iw}px;height:${ih}px;margin-left:${-iw / 2}px;border-radius:${ih}px;background:${c.f}"></span><span style="position:absolute;left:50%;bottom:${b + R(ih * 0.5)}px;width:${hb}px;height:${Math.max(3, R(w * 0.02))}px;margin-left:${-hb / 2}px;border-radius:4px;background:${c.f};opacity:.85"></span></div></div>`;
187
+ return { html, h };
188
+ },
189
+ monitor(o, k) {
190
+ const { c, w } = k;
191
+ const ratio = k.ratio[1] > k.ratio[0] ? [16, 10] : k.ratio;
192
+ const b = R(clamp(w * 0.025, 5, 10));
193
+ const chin = R(clamp(w * 0.055, 12, 20));
194
+ const mw = w - b * 2;
195
+ const mh = R(mw * ratio[1] / ratio[0]);
196
+ const nw = R(w * 0.12);
197
+ const nh = R(w * 0.09);
198
+ const bw = R(w * 0.38);
199
+ const bh = R(clamp(w * 0.022, 5, 9));
200
+ const h = b + mh + chin + nh + bh;
201
+ const html = `<div style="width:${w}px;height:${h}px;display:flex;flex-direction:column;align-items:center;filter:drop-shadow(0 ${R(w * 0.03)}px ${R(w * 0.05)}px rgba(0,0,0,.3))"><div style="width:${w}px;border-radius:${R(clamp(w * 0.03, 6, 12))}px;background:${c.f};padding:${b}px ${b}px 0;box-sizing:border-box">${mediaSurface(o, mw, mh, "3px")}<div style="height:${chin}px;display:flex;align-items:center;justify-content:center"><span style="width:${R(chin * 0.32)}px;height:${R(chin * 0.32)}px;border-radius:50%;background:${c.d};opacity:.35"></span></div></div><div style="width:${nw}px;height:${nh}px;flex:none;background:${c.f};clip-path:polygon(18% 0,82% 0,100% 100%,0 100%)"></div><div style="width:${bw}px;height:${bh}px;flex:none;border-radius:${bh}px;background:${c.f}"></div></div>`;
202
+ return { html, h };
203
+ },
204
+ polaroid(o, k) {
205
+ const { c, w, T, copy, shadow } = k;
206
+ const p = R(w * 0.06);
207
+ const bot = R(w * 0.24);
208
+ const mw = w - p * 2;
209
+ const mh = mw;
210
+ const h = p + mh + bot;
211
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length][0];
212
+ const tw = R(w * 0.36);
213
+ const th = R(clamp(w * 0.1, 14, 30));
214
+ const html = `<div style="position:relative;width:${w}px;height:${h}px"><div style="width:${w}px;height:${h}px;background:${c.f};border-radius:3px;padding:${p}px ${p}px 0;box-sizing:border-box;box-shadow:${shadow}">${mediaSurface(o, mw, mh, "1px")}<div style="height:${bot}px;display:flex;align-items:center;justify-content:center">` + (T === "S" && w < 180 ? "" : `<span style="font:italic 400 ${R(bot * 0.44)}px/1 'Instrument Serif',Georgia,serif;color:${c.d};opacity:.85">${cap}</span>`) + `</div></div><span style="position:absolute;left:50%;top:${-R(th * 0.45)}px;width:${tw}px;height:${th}px;margin-left:${-tw / 2}px;transform:rotate(-4deg);background:${hexA(c.d, 0.22)}"></span></div>`;
215
+ return { html, h };
216
+ },
217
+ stream(o, k) {
218
+ const { c, w, T, copy, font, rr, ring, shadow } = k;
219
+ const mh = R(w * k.ratio[1] / k.ratio[0]);
220
+ const h = mh;
221
+ const p = Math.max(8, R(w * 0.04));
222
+ const fs = clamp(R(w * 0.03), 9, 12);
223
+ const st = copy.stream ?? [copy.title, copy.caps[(o.idx ?? 0) % copy.caps.length][1]];
224
+ const live = `<div style="position:absolute;left:${p}px;top:${p}px;display:flex;align-items:center;gap:${R(fs * 0.5)}px;background:${c.f};color:${c.d};border-radius:4px;padding:${R(fs * 0.45)}px ${R(fs * 0.6)}px;font:500 ${fs}px/1 ${MONO};letter-spacing:.1em"><span style="width:${R(fs * 0.65)}px;height:${R(fs * 0.65)}px;border-radius:50%;background:${c.d}"></span>${T === "S" ? "" : "LIVE"}</div>`;
225
+ const viewers = T === "L" ? `<div style="position:absolute;right:${p}px;top:${p}px;background:${hexA(c.d, 0.75)};color:${c.f};border-radius:4px;padding:${R(fs * 0.45)}px ${R(fs * 0.6)}px;font:500 ${fs}px/1 ${MONO};letter-spacing:.06em">12.4K WATCHING</div>` : "";
226
+ const lh = R(clamp(w * 0.085, 18, 32));
227
+ const lower = T === "S" ? "" : `<div style="position:absolute;left:${p}px;bottom:${p}px;right:${R(w * 0.22)}px;height:${lh}px;display:flex"><div style="flex:1;min-width:0;background:${c.f};color:${c.d};display:flex;align-items:center;padding:0 ${R(lh * 0.4)}px;font:800 ${R(lh * 0.44)}px/1 ${font};white-space:nowrap;overflow:hidden">${T === "L" ? st[0] : ""}</div><div style="background:${c.d};color:${c.f};display:flex;align-items:center;padding:0 ${R(lh * 0.35)}px;font:500 ${R(lh * 0.42)}px/1 ${MONO};white-space:nowrap">${st[1]}</div></div>`;
228
+ const html = `<div style="position:relative;width:${w}px;height:${h}px;border-radius:${rr}px;box-shadow:0 0 0 ${ring}px ${c.f},${shadow};overflow:hidden">${mediaSurface({ ...o, clip: false }, w, mh, `${rr}px`)}${live}${viewers}${lower}</div>`;
229
+ return { html, h };
230
+ },
231
+ post(o, k) {
232
+ const { c, w, T, copy, font, rr, shadow } = k;
233
+ const p = R(clamp(w * 0.045, 8, 14));
234
+ const av = R(clamp(w * 0.1, 18, 32));
235
+ const mw = w - p * 2;
236
+ const mh = R(mw * k.ratio[1] / k.ratio[0]);
237
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length];
238
+ const who = T === "L" ? `<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font:800 ${R(av * 0.42)}px/1 ${font};color:${c.d};white-space:nowrap">${cap[0]}</span><span style="font:500 ${R(av * 0.3)}px/1 ${MONO};color:${c.d};opacity:.6;white-space:nowrap">@${cap[0].toLowerCase().replace(/[^a-z0-9]/g, "") || "user"}</span></div>` : `<div style="display:flex;flex-direction:column;gap:${R(av * 0.16)}px;flex:1"><span style="display:block;width:${T === "M" ? "46%" : "60%"};height:${R(av * 0.26)}px;border-radius:4px;background:${c.d};opacity:.55"></span>` + (T === "M" ? `<span style="display:block;width:30%;height:${R(av * 0.18)}px;border-radius:4px;background:${c.d};opacity:.3"></span>` : "") + `</div>`;
239
+ const head = `<div style="height:${av}px;display:flex;align-items:center;gap:${R(av * 0.35)}px;margin-bottom:${p}px"><span style="width:${av}px;height:${av}px;border-radius:50%;background:${c.d};flex:none"></span>${who}</div>`;
240
+ const ic = R(clamp(w * 0.055, 10, 18));
241
+ const icons = `<div style="display:flex;gap:${R(ic * 0.9)}px;align-items:center;height:${ic}px;margin-top:${p}px">` + [
242
+ "M12 21C5 16 2 12 2 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 10 2.5C22 12 19 16 12 21z",
243
+ "M4 4h16v12H9l-5 4z",
244
+ "M3 12l18-8-7 17-3-7z"
245
+ ].map(
246
+ (d) => `<svg width="${ic}" height="${ic}" viewBox="0 0 24 24" fill="none" stroke="${c.d}" stroke-width="2.6" stroke-linejoin="round" style="display:block"><path d="${d}"/></svg>`
247
+ ).join("") + `</div>`;
248
+ const h = p + av + p + mh + p + ic + p;
249
+ const html = `<div style="width:${w}px;height:${h}px;box-sizing:border-box;padding:${p}px;border-radius:${rr}px;background:${c.f};box-shadow:${shadow}">${head}${mediaSurface(o, mw, mh, `${R(rr * 0.6)}px`)}${icons}</div>`;
250
+ return { html, h };
251
+ },
252
+ circle(o, k) {
253
+ const { c, w, shadow } = k;
254
+ const rw = R(clamp(w * 0.035, 4, 8));
255
+ const dot = R(w * 0.2);
256
+ const mw = w - rw * 2;
257
+ const html = `<div style="position:relative;width:${w}px;height:${w}px">` + (w >= 170 ? `<div style="position:absolute;inset:${-R(w * 0.07)}px;border-radius:50%;border:2px dashed ${c.f};opacity:.5"></div>` : "") + `<div style="width:${w}px;height:${w}px;border-radius:50%;background:${c.f};padding:${rw}px;box-sizing:border-box;box-shadow:${shadow}">${mediaSurface({ ...o, clip: false }, mw, mw, "50%")}</div><span style="position:absolute;right:${R(w * 0.03)}px;bottom:${R(w * 0.03)}px;width:${dot}px;height:${dot}px;border-radius:50%;background:${c.f};display:flex;align-items:center;justify-content:center"><span style="width:${R(dot * 0.5)}px;height:${R(dot * 0.5)}px;border-radius:50%;background:${c.d}"></span></span></div>`;
258
+ return { html, h: w };
259
+ },
260
+ message(o, k) {
261
+ const { c, w, T } = k;
262
+ const p = R(clamp(w * 0.03, 5, 9));
263
+ const mw = w - p * 2;
264
+ const mh = R(mw * k.ratio[1] / k.ratio[0]);
265
+ const rad = R(clamp(w * 0.07, 12, 22));
266
+ const foot = T === "S" && w < 220 ? 0 : R(clamp(w * 0.075, 16, 24));
267
+ const h = p + mh + (foot || p);
268
+ const tail = R(clamp(w * 0.06, 10, 18));
269
+ const html = `<div style="position:relative;width:${w}px;height:${h}px;filter:drop-shadow(0 ${R(w * 0.025)}px ${R(w * 0.04)}px rgba(0,0,0,.3))"><div style="width:${w}px;height:${h}px;box-sizing:border-box;padding:${p}px ${p}px 0;border-radius:${rad}px ${rad}px ${rad}px 4px;background:${c.f}">${mediaSurface(o, mw, mh, `${rad - p}px`)}` + (foot ? `<div style="height:${foot}px;display:flex;align-items:center;justify-content:flex-end;gap:5px;font:500 ${R(foot * 0.5)}px/1 ${MONO};color:${c.d};opacity:.65">21:04 <span style="letter-spacing:-0.25em">\u2713\u2713</span></div>` : "") + `</div><svg width="${tail}" height="${tail}" viewBox="0 0 10 10" style="position:absolute;left:${-R(tail * 0.55)}px;bottom:0;display:block"><path d="M10 0v10H0c4-1 7-4 10-10z" fill="${c.f}"/></svg></div>`;
270
+ return { html, h };
271
+ },
272
+ slide(o, k) {
273
+ const { c, w, copy, shadow } = k;
274
+ const rad = R(w * 0.05);
275
+ const aw = R(w * 0.74);
276
+ const ah = R(aw * 0.75);
277
+ const top = R((w - ah) * 0.38);
278
+ const sx = R((w - aw) / 2);
279
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length][0];
280
+ const html = `<div style="position:relative;width:${w}px;height:${w}px;border-radius:${rad}px;background:${c.f};box-shadow:${shadow}"><div style="position:absolute;left:${sx}px;top:${top}px;border-radius:${R(aw * 0.06)}px;overflow:hidden;box-shadow:0 0 0 2px ${hexA(c.d, 0.25)}">${mediaSurface({ ...o, clip: false }, aw, ah, `${R(aw * 0.06)}px`)}</div>` + (w >= 180 ? `<div style="position:absolute;left:${sx}px;right:${sx}px;bottom:${R(w * 0.065)}px;display:flex;justify-content:space-between;font:500 ${clamp(R(w * 0.045), 9, 12)}px/1 ${MONO};letter-spacing:.12em;color:${c.d};opacity:.7"><span>${cap.toUpperCase()}</span><span>${String((o.idx ?? 0) + 1).padStart(2, "0")}</span></div>` : "") + `<span style="position:absolute;left:${R(w * 0.06)}px;top:${R(w * 0.06)}px;width:${R(w * 0.04)}px;height:${R(w * 0.04)}px;border-radius:50%;background:${c.d};opacity:.3"></span></div>`;
281
+ return { html, h: w };
282
+ },
283
+ ticket(o, k) {
284
+ const { c, w, T, copy, font, rr } = k;
285
+ const sw = R(w * 0.28);
286
+ const mw = w - sw;
287
+ const ratio = k.ratio[1] > k.ratio[0] ? [4, 3] : k.ratio;
288
+ const mh = R(mw * ratio[1] / ratio[0]);
289
+ const h = mh;
290
+ const nr = R(clamp(w * 0.03, 6, 12));
291
+ const mask = `radial-gradient(circle at ${mw}px 0,transparent ${nr}px,#000 ${nr + 0.5}px) top/100% 51% no-repeat,radial-gradient(circle at ${mw}px 100%,transparent ${nr}px,#000 ${nr + 0.5}px) bottom/100% 51% no-repeat`;
292
+ const bars = [2, 1, 3, 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 2].map((bw) => `<span style="display:block;width:${bw}px;height:100%;background:${c.d}"></span>`).join("");
293
+ const fs = clamp(R(sw * 0.08), 8, 10);
294
+ const info = T === "L" ? `<div style="display:flex;flex-direction:column;gap:5px"><span style="font:500 ${fs}px/1 ${MONO};letter-spacing:.14em;color:${c.d};opacity:.65">ADMIT ONE</span><span style="font:800 ${R(sw * 0.13)}px/1.05 ${font};color:${c.d};letter-spacing:-0.01em">${copy.title}</span><span style="font:500 ${fs}px/1 ${MONO};letter-spacing:.12em;color:${c.d}">\u2116 00${(o.idx ?? 0) + 1}</span></div>` : "<div></div>";
295
+ const stub = `<div style="width:${sw}px;height:${h}px;flex:none;background:${c.f};border-left:2px dashed ${hexA(c.d, 0.35)};box-sizing:border-box;display:flex;flex-direction:column;justify-content:space-between;padding:${R(sw * 0.12)}px">${info}` + (T === "S" ? "" : `<div style="display:flex;gap:2px;height:${R(sw * 0.2)}px">${bars}</div>`) + `</div>`;
296
+ const html = `<div style="width:${w}px;height:${h}px;filter:drop-shadow(0 ${R(w * 0.025)}px ${R(w * 0.04)}px rgba(0,0,0,.3))"><div style="display:flex;width:${w}px;height:${h}px;border-radius:${rr}px;overflow:hidden;-webkit-mask:${mask};mask:${mask}">${mediaSurface({ ...o, clip: false }, mw, mh, "0")}${stub}</div></div>`;
297
+ return { html, h };
298
+ }
299
+ };
300
+ function frame(o) {
301
+ const kind = o.kind ?? "browser";
302
+ const c = o.c;
303
+ const w = R(o.w);
304
+ const T = tier(w);
305
+ const ratio = FORCE[kind] ?? o.ratio ?? [16, 9];
306
+ const copy = (o.pack ? COPY[o.pack] : void 0) ?? fallbackCopy;
307
+ const font = o.font ?? FONT;
308
+ const rr = R(clamp(w * 0.035, 8, 14));
309
+ const shadow = `0 ${R(w * 0.03)}px ${R(w * 0.08)}px rgba(0,0,0,.32)`;
310
+ const ring = R(clamp(w * 8e-3, 2, 3));
311
+ const ctx = { c, w, T, ratio, copy, font, rr, shadow, ring };
312
+ const build = BUILDERS[kind];
313
+ if (build) {
314
+ const { html: html2, h } = build(o, ctx);
315
+ return { html: html2, h, tier: T };
316
+ }
317
+ const mh = R(w * ratio[1] / ratio[0]);
318
+ const html = `<div style="width:${w}px;height:${mh}px;border-radius:${rr}px;box-shadow:${o.ring === false ? "" : `0 0 0 ${ring}px ${c.f},`}${shadow};overflow:hidden">${mediaSurface(o, w, mh, `${rr}px`)}</div>`;
319
+ return { html, h: mh, tier: T };
320
+ }
321
+
322
+ // src/index.ts
323
+ var PROBE_W = 320;
324
+ function windowShape(o) {
325
+ const kind = o.kind ?? "browser";
326
+ const probeColors = { f: "#000000", d: "#ffffff" };
327
+ const probe = frame({
328
+ ...o,
329
+ kind,
330
+ w: PROBE_W,
331
+ c: probeColors
332
+ });
333
+ const ar = PROBE_W / probe.h;
334
+ const def = {
335
+ id: o.id,
336
+ label: o.label ?? o.id,
337
+ kind: "obj",
338
+ ar,
339
+ scale: (o.scale ?? 3) * (SCALE[kind] ?? 1),
340
+ /**
341
+ * Builds at the particle's real width, not a reference one, so chrome
342
+ * picks the right tier — a 240px frame merges its controls into a single
343
+ * capsule, a 420px one shows a URL. Building at a fixed width and scaling
344
+ * would both overflow the particle box and freeze the chrome at one tier.
345
+ */
346
+ render: (c, _variant, width) => frame({ ...o, kind, w: width ?? PROBE_W, c }).html
347
+ };
348
+ return o.place === void 0 ? def : { ...def, place: o.place };
349
+ }
350
+ function image(o) {
351
+ return windowShape({ ...o, kind: "bare" });
352
+ }
353
+ function video(o) {
354
+ return windowShape({ ...o, kind: "bare", clip: o.clip ?? true });
355
+ }
356
+
357
+ exports.COPY = COPY;
358
+ exports.KINDS = KINDS;
359
+ exports.SCALE = SCALE;
360
+ exports.frame = frame;
361
+ exports.image = image;
362
+ exports.isVideo = isVideo;
363
+ exports.mediaSurface = mediaSurface;
364
+ exports.tier = tier;
365
+ exports.video = video;
366
+ exports.windowShape = windowShape;
@@ -0,0 +1,145 @@
1
+ import { ShapeColors, Anchor, ShapeDef } from '@usemote/core';
2
+
3
+ type Tier = 'S' | 'M' | 'L';
4
+ /**
5
+ * Chrome detail is chosen by rendered width, not by a breakpoint.
6
+ *
7
+ * A frame floating in a field can be any size, and chrome that merely scales
8
+ * turns to mush below ~270px — traffic lights become 5px smudges and a URL
9
+ * becomes a grey streak. Each tier drops detail rather than shrinking it.
10
+ */
11
+ declare const tier: (w: number) => Tier;
12
+
13
+ type FrameKind = 'browser' | 'app' | 'bare' | 'caption' | 'phone' | 'monitor' | 'polaroid' | 'stream' | 'post' | 'circle' | 'message' | 'slide' | 'ticket';
14
+ /** Realistic chrome text per pack, so a frame reads as that domain. */
15
+ interface PackCopy {
16
+ url: string;
17
+ title: string;
18
+ caps: Array<[string, string]>;
19
+ stream?: [string, string];
20
+ }
21
+ interface MediaSource {
22
+ /** Image or video URL. Video is detected by extension. */
23
+ src?: string;
24
+ /**
25
+ * Poster frame, shown until playback starts — and permanently under
26
+ * reduced motion, where playback never starts at all. Strongly recommended
27
+ * for video: without it a paused `<video>` is a blank rectangle.
28
+ */
29
+ poster?: string;
30
+ /** Alternative text. Decorative by default, since the layer is aria-hidden. */
31
+ alt?: string;
32
+ /** SVG markup shown in the placeholder when there is no `src`. */
33
+ art?: string;
34
+ /** Adds a progress bar and timecode chip, for clip-style content. */
35
+ clip?: boolean;
36
+ }
37
+ interface FrameOptions extends MediaSource {
38
+ kind?: FrameKind;
39
+ /** Rendered width in px. Height follows from the kind and ratio. */
40
+ w: number;
41
+ /** Colours, normally handed in by the field from the active theme. */
42
+ c: ShapeColors;
43
+ ratio?: [number, number];
44
+ /** Pack id, selecting the chrome copy. */
45
+ pack?: string;
46
+ font?: string;
47
+ /** Index into the pack's caption list, so sibling frames differ. */
48
+ idx?: number;
49
+ /** `bare` only: drop the outline ring. */
50
+ ring?: boolean;
51
+ }
52
+ interface FrameResult {
53
+ html: string;
54
+ /** Rendered height in px, for the caller's aspect ratio. */
55
+ h: number;
56
+ tier: Tier;
57
+ }
58
+
59
+ /** Relative size, so one `size` value gives sane results across kinds. */
60
+ declare const SCALE: Partial<Record<FrameKind, number>>;
61
+ declare const KINDS: FrameKind[];
62
+ /** Builds one frame. Returns markup plus the height it actually occupies. */
63
+ declare function frame(o: FrameOptions): FrameResult;
64
+
65
+ declare const isVideo: (src: string) => boolean;
66
+ declare function mediaSurface(o: FrameOptions, mw: number, mh: number, rad: string): string;
67
+
68
+ /**
69
+ * Per-pack chrome copy, from the design source.
70
+ *
71
+ * A browser frame in a devtools field should read `localhost:3000`, not
72
+ * `example.com` — the chrome is part of the pack's vocabulary, and generic
73
+ * placeholder text is what makes a mockup look like a mockup.
74
+ */
75
+
76
+ declare const COPY: Record<string, PackCopy>;
77
+
78
+ /**
79
+ * @usemote/media — window frames and media particles for Mote.
80
+ *
81
+ * A particle does not have to be a drawn shape. The renderer only ever
82
+ * assigns a shape's markup as the particle's contents, so a photo, a GIF or a
83
+ * framed video participates in the field exactly like any geometric shape:
84
+ * same layout, same depth, same motion presets, same scheduler.
85
+ *
86
+ * ```ts
87
+ * import { windowShape } from '@usemote/media';
88
+ *
89
+ * <FloatingField
90
+ * packs={[esports]} mode="bob" count={10}
91
+ * media={[
92
+ * windowShape({ id: 'nova', kind: 'caption', src: '/nova.jpg', place: 'right', scale: 3.4 }),
93
+ * windowShape({ id: 'demo', kind: 'browser', src: '/demo.webm', poster: '/demo.jpg', clip: true, place: 'bottom-left' }),
94
+ * ]}
95
+ * />
96
+ * ```
97
+ */
98
+
99
+ interface WindowShapeOptions extends MediaSource {
100
+ /** Unique id, as for any shape. */
101
+ id: string;
102
+ /** Human label, shown in pickers. Defaults to the id. */
103
+ label?: string;
104
+ kind?: FrameKind;
105
+ /** Aspect ratio of the media area. Ignored by kinds with a fixed shape. */
106
+ ratio?: [number, number];
107
+ /** Where to anchor it. Media is too large for `avoid` to place. */
108
+ place?: Anchor;
109
+ /**
110
+ * Size multiplier on the field's `size`. Frames want to be much larger than
111
+ * shapes, so this defaults to 3 rather than 1.
112
+ */
113
+ scale?: number;
114
+ /** Pack id, selecting the chrome copy (`localhost:3000` vs `app.ledger.io`). */
115
+ pack?: string;
116
+ font?: string;
117
+ /** Index into the pack's caption list, so sibling frames read differently. */
118
+ idx?: number;
119
+ /** `bare` only: drop the outline ring. */
120
+ ring?: boolean;
121
+ }
122
+ /**
123
+ * Wraps a frame as a `ShapeDef`, so it can be passed to `media` on a field.
124
+ *
125
+ * `ar` is measured by building the frame once at a reference width: chrome is
126
+ * not a fixed fraction of the media area — a caption strip is clamped between
127
+ * 30 and 46px — so the only honest aspect ratio is the one the builder
128
+ * actually produces. Layout uses `ar` for spacing and collision, so guessing
129
+ * it would make frames crowd their neighbours.
130
+ */
131
+ declare function windowShape(o: WindowShapeOptions): ShapeDef;
132
+ /** A photo or GIF with no chrome at all. Shorthand for `kind: 'bare'`. */
133
+ declare function image(o: Omit<WindowShapeOptions, 'kind'> & {
134
+ src: string;
135
+ }): ShapeDef;
136
+ /**
137
+ * A video with no chrome. Playback is started by the renderer, never by an
138
+ * `autoplay` attribute, so reduced motion and offscreen fields stay on the
139
+ * poster frame.
140
+ */
141
+ declare function video(o: Omit<WindowShapeOptions, 'kind'> & {
142
+ src: string;
143
+ }): ShapeDef;
144
+
145
+ export { COPY, type FrameKind, type FrameOptions, type FrameResult, KINDS, type MediaSource, type PackCopy, SCALE, type Tier, type WindowShapeOptions, frame, image, isVideo, mediaSurface, tier, video, windowShape };
@@ -0,0 +1,145 @@
1
+ import { ShapeColors, Anchor, ShapeDef } from '@usemote/core';
2
+
3
+ type Tier = 'S' | 'M' | 'L';
4
+ /**
5
+ * Chrome detail is chosen by rendered width, not by a breakpoint.
6
+ *
7
+ * A frame floating in a field can be any size, and chrome that merely scales
8
+ * turns to mush below ~270px — traffic lights become 5px smudges and a URL
9
+ * becomes a grey streak. Each tier drops detail rather than shrinking it.
10
+ */
11
+ declare const tier: (w: number) => Tier;
12
+
13
+ type FrameKind = 'browser' | 'app' | 'bare' | 'caption' | 'phone' | 'monitor' | 'polaroid' | 'stream' | 'post' | 'circle' | 'message' | 'slide' | 'ticket';
14
+ /** Realistic chrome text per pack, so a frame reads as that domain. */
15
+ interface PackCopy {
16
+ url: string;
17
+ title: string;
18
+ caps: Array<[string, string]>;
19
+ stream?: [string, string];
20
+ }
21
+ interface MediaSource {
22
+ /** Image or video URL. Video is detected by extension. */
23
+ src?: string;
24
+ /**
25
+ * Poster frame, shown until playback starts — and permanently under
26
+ * reduced motion, where playback never starts at all. Strongly recommended
27
+ * for video: without it a paused `<video>` is a blank rectangle.
28
+ */
29
+ poster?: string;
30
+ /** Alternative text. Decorative by default, since the layer is aria-hidden. */
31
+ alt?: string;
32
+ /** SVG markup shown in the placeholder when there is no `src`. */
33
+ art?: string;
34
+ /** Adds a progress bar and timecode chip, for clip-style content. */
35
+ clip?: boolean;
36
+ }
37
+ interface FrameOptions extends MediaSource {
38
+ kind?: FrameKind;
39
+ /** Rendered width in px. Height follows from the kind and ratio. */
40
+ w: number;
41
+ /** Colours, normally handed in by the field from the active theme. */
42
+ c: ShapeColors;
43
+ ratio?: [number, number];
44
+ /** Pack id, selecting the chrome copy. */
45
+ pack?: string;
46
+ font?: string;
47
+ /** Index into the pack's caption list, so sibling frames differ. */
48
+ idx?: number;
49
+ /** `bare` only: drop the outline ring. */
50
+ ring?: boolean;
51
+ }
52
+ interface FrameResult {
53
+ html: string;
54
+ /** Rendered height in px, for the caller's aspect ratio. */
55
+ h: number;
56
+ tier: Tier;
57
+ }
58
+
59
+ /** Relative size, so one `size` value gives sane results across kinds. */
60
+ declare const SCALE: Partial<Record<FrameKind, number>>;
61
+ declare const KINDS: FrameKind[];
62
+ /** Builds one frame. Returns markup plus the height it actually occupies. */
63
+ declare function frame(o: FrameOptions): FrameResult;
64
+
65
+ declare const isVideo: (src: string) => boolean;
66
+ declare function mediaSurface(o: FrameOptions, mw: number, mh: number, rad: string): string;
67
+
68
+ /**
69
+ * Per-pack chrome copy, from the design source.
70
+ *
71
+ * A browser frame in a devtools field should read `localhost:3000`, not
72
+ * `example.com` — the chrome is part of the pack's vocabulary, and generic
73
+ * placeholder text is what makes a mockup look like a mockup.
74
+ */
75
+
76
+ declare const COPY: Record<string, PackCopy>;
77
+
78
+ /**
79
+ * @usemote/media — window frames and media particles for Mote.
80
+ *
81
+ * A particle does not have to be a drawn shape. The renderer only ever
82
+ * assigns a shape's markup as the particle's contents, so a photo, a GIF or a
83
+ * framed video participates in the field exactly like any geometric shape:
84
+ * same layout, same depth, same motion presets, same scheduler.
85
+ *
86
+ * ```ts
87
+ * import { windowShape } from '@usemote/media';
88
+ *
89
+ * <FloatingField
90
+ * packs={[esports]} mode="bob" count={10}
91
+ * media={[
92
+ * windowShape({ id: 'nova', kind: 'caption', src: '/nova.jpg', place: 'right', scale: 3.4 }),
93
+ * windowShape({ id: 'demo', kind: 'browser', src: '/demo.webm', poster: '/demo.jpg', clip: true, place: 'bottom-left' }),
94
+ * ]}
95
+ * />
96
+ * ```
97
+ */
98
+
99
+ interface WindowShapeOptions extends MediaSource {
100
+ /** Unique id, as for any shape. */
101
+ id: string;
102
+ /** Human label, shown in pickers. Defaults to the id. */
103
+ label?: string;
104
+ kind?: FrameKind;
105
+ /** Aspect ratio of the media area. Ignored by kinds with a fixed shape. */
106
+ ratio?: [number, number];
107
+ /** Where to anchor it. Media is too large for `avoid` to place. */
108
+ place?: Anchor;
109
+ /**
110
+ * Size multiplier on the field's `size`. Frames want to be much larger than
111
+ * shapes, so this defaults to 3 rather than 1.
112
+ */
113
+ scale?: number;
114
+ /** Pack id, selecting the chrome copy (`localhost:3000` vs `app.ledger.io`). */
115
+ pack?: string;
116
+ font?: string;
117
+ /** Index into the pack's caption list, so sibling frames read differently. */
118
+ idx?: number;
119
+ /** `bare` only: drop the outline ring. */
120
+ ring?: boolean;
121
+ }
122
+ /**
123
+ * Wraps a frame as a `ShapeDef`, so it can be passed to `media` on a field.
124
+ *
125
+ * `ar` is measured by building the frame once at a reference width: chrome is
126
+ * not a fixed fraction of the media area — a caption strip is clamped between
127
+ * 30 and 46px — so the only honest aspect ratio is the one the builder
128
+ * actually produces. Layout uses `ar` for spacing and collision, so guessing
129
+ * it would make frames crowd their neighbours.
130
+ */
131
+ declare function windowShape(o: WindowShapeOptions): ShapeDef;
132
+ /** A photo or GIF with no chrome at all. Shorthand for `kind: 'bare'`. */
133
+ declare function image(o: Omit<WindowShapeOptions, 'kind'> & {
134
+ src: string;
135
+ }): ShapeDef;
136
+ /**
137
+ * A video with no chrome. Playback is started by the renderer, never by an
138
+ * `autoplay` attribute, so reduced motion and offscreen fields stay on the
139
+ * poster frame.
140
+ */
141
+ declare function video(o: Omit<WindowShapeOptions, 'kind'> & {
142
+ src: string;
143
+ }): ShapeDef;
144
+
145
+ export { COPY, type FrameKind, type FrameOptions, type FrameResult, KINDS, type MediaSource, type PackCopy, SCALE, type Tier, type WindowShapeOptions, frame, image, isVideo, mediaSurface, tier, video, windowShape };
package/dist/index.js ADDED
@@ -0,0 +1,355 @@
1
+ // src/copy.ts
2
+ var COPY = {
3
+ esports: {
4
+ url: "publication.gg/live",
5
+ title: "Grand Final \xB7 Lagos",
6
+ stream: ["K9 vs NOVA", "2\u20131"],
7
+ caps: [["K9", "ACCRA"], ["NOVA", "LAGOS"], ["ADA", "ACCRA"], ["DEMZY", "LAGOS"]]
8
+ },
9
+ devtools: {
10
+ url: "localhost:3000",
11
+ title: "deploy \u2014 main",
12
+ caps: [["v2.4.0", "SHIPPED"], ["main", "GREEN"], ["#128", "MERGED"], ["api", "200 OK"]]
13
+ },
14
+ ai: {
15
+ url: "chat.model.ai",
16
+ title: "Inference",
17
+ caps: [["Prompt", "TEXT"], ["Vision", "IMAGE"], ["Voice", "AUDIO"], ["Agent", "RUN"]]
18
+ },
19
+ fintech: {
20
+ url: "app.ledger.io",
21
+ title: "Portfolio",
22
+ caps: [["+2.4%", "TODAY"], ["Savings", "GOAL"], ["Card", "ACTIVE"], ["NGN/USD", "LIVE"]]
23
+ },
24
+ creative: {
25
+ url: "studio.work",
26
+ title: "Moodboard",
27
+ caps: [["Brand", "V3"], ["Poster", "DRAFT"], ["Type", "SPEC"], ["Motion", "WIP"]]
28
+ },
29
+ space: {
30
+ url: "mission.live",
31
+ title: "Telemetry",
32
+ caps: [["Orbit 3", "LIVE"], ["Mars", "SOL 214"], ["Launch", "T-10"], ["Lunar", "PASS"]]
33
+ },
34
+ cyber: {
35
+ url: "vault.secure",
36
+ title: "audit.log",
37
+ caps: [["Firewall", "ON"], ["2FA", "VERIFIED"], ["Scan", "CLEAN"], ["Vault", "LOCKED"]]
38
+ },
39
+ health: {
40
+ url: "care.app",
41
+ title: "Vitals",
42
+ caps: [["72 BPM", "REST"], ["Sleep", "7H 40"], ["Steps", "9,420"], ["Dose", "08:00"]]
43
+ },
44
+ education: {
45
+ url: "learn.school",
46
+ title: "Lesson 04",
47
+ caps: [["Algebra", "UNIT 2"], ["Physics", "LAB"], ["History", "QUIZ"], ["Art", "STUDIO"]]
48
+ }
49
+ };
50
+ var fallbackCopy = COPY.esports;
51
+
52
+ // src/util.ts
53
+ var R = Math.round;
54
+ var clamp = (v, a, b) => Math.max(a, Math.min(b, v));
55
+ var hexA = (hex, a) => /^#[0-9a-f]{6}$/i.test(hex) ? hex + R(a * 255).toString(16).padStart(2, "0") : hex;
56
+ var FONT = "'Schibsted Grotesk',system-ui,-apple-system,sans-serif";
57
+ var MONO = "'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace";
58
+ var tier = (w) => w >= 340 ? "L" : w >= 270 ? "M" : "S";
59
+ var lock = (s, col) => `<svg width="${s}" height="${R(s * 1.2)}" viewBox="0 0 10 12" style="flex:none;display:block"><path d="M2.5 5.5V3.5a2.5 2.5 0 0 1 5 0v2" fill="none" stroke="${col}" stroke-width="1.6"/><rect x="1" y="5" width="8" height="6.5" rx="1.6" fill="${col}"/></svg>`;
60
+ var lights = (T, dot, gap, col) => T === "S" ? `<span style="display:block;width:${dot * 3 + gap * 2}px;height:${dot}px;border-radius:${dot}px;background:${col};opacity:.55"></span>` : [1, 0.6, 0.35].map(
61
+ (o) => `<span style="display:block;flex:none;width:${dot}px;height:${dot}px;border-radius:50%;background:${col};opacity:${o}"></span>`
62
+ ).join("");
63
+
64
+ // src/media.ts
65
+ var VIDEO_EXT = /\.(mp4|webm|mov|m4v)(\?|#|$)/i;
66
+ var isVideo = (src) => VIDEO_EXT.test(src);
67
+ var attr = (v) => v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
68
+ function mediaSurface(o, mw, mh, rad) {
69
+ const c = o.c;
70
+ let inner;
71
+ if (o.src && isVideo(o.src)) {
72
+ inner = `<video data-mote-media src="${attr(o.src)}"` + (o.poster ? ` poster="${attr(o.poster)}"` : "") + ` muted loop playsinline preload="metadata" tabindex="-1" aria-hidden="true" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block"></video>`;
73
+ } else if (o.src) {
74
+ inner = `<img src="${attr(o.src)}" alt="${attr(o.alt ?? "")}" loading="lazy" decoding="async" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block">`;
75
+ } else {
76
+ const s = R(Math.min(mw, mh) * 0.46);
77
+ inner = `<div style="position:absolute;inset:0;background:${c.d};background-image:repeating-linear-gradient(135deg,${hexA(c.f, 0.08)} 0 10px,transparent 10px 20px)"></div>` + (o.art ? `<div style="position:absolute;left:50%;top:50%;width:${s}px;height:${s}px;margin:${-s / 2}px 0 0 ${-s / 2}px">${o.art}</div>` : "");
78
+ }
79
+ if (o.clip) {
80
+ const bh = Math.max(4, R(mw * 0.014));
81
+ const p = Math.max(8, R(mw * 0.045));
82
+ inner += `<div style="position:absolute;left:${p}px;right:${p}px;bottom:${p}px;height:${bh}px;border-radius:${bh}px;background:${hexA(c.f, 0.35)}"><div style="width:38%;height:100%;border-radius:${bh}px;background:${c.f}"></div></div>`;
83
+ if (mw >= 300) {
84
+ const fs = clamp(R(mw * 0.032), 10, 12);
85
+ inner += `<div style="position:absolute;left:${p}px;top:${p}px;display:flex;align-items:center;gap:6px;background:${c.f};color:${c.d};border-radius:4px;padding:4px 7px;font:500 ${fs}px/1 ${MONO};letter-spacing:.08em"><span style="width:0;height:0;border-left:${R(fs * 0.7)}px solid ${c.d};border-top:${R(fs * 0.42)}px solid transparent;border-bottom:${R(fs * 0.42)}px solid transparent"></span>0:24</div>`;
86
+ }
87
+ }
88
+ return `<div style="position:relative;width:${mw}px;height:${mh}px;overflow:hidden;border-radius:${rad};background:${c.d}">${inner}</div>`;
89
+ }
90
+
91
+ // src/frames.ts
92
+ var FORCE = {
93
+ phone: [9, 19.5],
94
+ circle: [1, 1],
95
+ slide: [4, 3],
96
+ polaroid: [1, 1]
97
+ };
98
+ var SCALE = {
99
+ phone: 0.5,
100
+ circle: 0.62,
101
+ slide: 0.72,
102
+ polaroid: 0.78,
103
+ message: 0.85,
104
+ post: 0.82,
105
+ ticket: 1.1,
106
+ monitor: 1.05
107
+ };
108
+ var KINDS = [
109
+ "browser",
110
+ "app",
111
+ "bare",
112
+ "caption",
113
+ "phone",
114
+ "monitor",
115
+ "polaroid",
116
+ "stream",
117
+ "post",
118
+ "circle",
119
+ "message",
120
+ "slide",
121
+ "ticket"
122
+ ];
123
+ var BUILDERS = {
124
+ browser(o, k) {
125
+ const { c, w, T, ratio, copy, rr, shadow } = k;
126
+ const rim = R(clamp(w * 0.012, 3, 5));
127
+ const bar = R(clamp(w * 0.08, 20, 30));
128
+ const dot = R(clamp(bar * 0.34, 7, 10));
129
+ const gap = R(dot * 0.6);
130
+ const pad = R(bar * 0.45);
131
+ const mw = w - rim * 2;
132
+ const mh = R(mw * ratio[1] / ratio[0]);
133
+ const h = bar + mh + rim;
134
+ const lw = dot * 3 + gap * 2;
135
+ const ph = R(bar * 0.52);
136
+ const url = T === "L" ? `${lock(R(ph * 0.42), c.d)}<span style="font:500 ${clamp(R(bar * 0.36), 9, 11)}px/1 ${MONO};color:${c.d};opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${copy.url}</span>` : "";
137
+ const html = `<div style="width:${w}px;height:${h}px;display:flex;flex-direction:column;border-radius:${rr}px;background:${c.f};box-shadow:${shadow};overflow:hidden"><div style="height:${bar}px;flex:none;display:grid;grid-template-columns:${lw}px minmax(0,1fr) ${lw}px;align-items:center;gap:${pad}px;padding:0 ${pad}px"><div style="display:flex;gap:${gap}px;align-items:center">${lights(T, dot, gap, c.d)}</div><div style="display:flex;justify-content:center;min-width:0"><div style="height:${ph}px;width:${T === "S" ? "70%" : "86%"};border-radius:${ph}px;background:${hexA(c.d, 0.16)};display:flex;align-items:center;justify-content:center;gap:5px;padding:0 8px;box-sizing:border-box;min-width:0">${url}</div></div><div></div></div><div style="padding:0 ${rim}px ${rim}px">${mediaSurface(o, mw, mh, `0 0 ${Math.max(2, rr - rim)}px ${Math.max(2, rr - rim)}px`)}</div></div>`;
138
+ return { html, h };
139
+ },
140
+ app(o, k) {
141
+ const { c, w, T, ratio, copy, font, rr, shadow } = k;
142
+ const rim = R(clamp(w * 0.012, 3, 5));
143
+ const bar = R(clamp(w * 0.095, 22, 34));
144
+ const dot = R(clamp(bar * 0.3, 7, 10));
145
+ const gap = R(dot * 0.6);
146
+ const pad = R(bar * 0.42);
147
+ const rail = T === "S" ? 0 : R(clamp(w * 0.075, 20, 30));
148
+ const foot = R(clamp(w * 0.035, 8, 12));
149
+ const mw = w - rim - (rail || rim);
150
+ const mh = R(mw * ratio[1] / ratio[0]);
151
+ const h = bar + mh + foot;
152
+ const lw = dot * 3 + gap * 2;
153
+ const title = T === "L" ? `<span style="font:700 ${clamp(R(bar * 0.38), 10, 13)}px/1 ${font};color:${c.d};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${copy.title}</span>` : T === "M" ? `<span style="display:block;width:${R(w * 0.22)}px;height:${R(dot * 0.8)}px;border-radius:${dot}px;background:${c.d};opacity:.35"></span>` : "";
154
+ const right = T === "S" ? "" : [0, 1].map(
155
+ () => `<span style="display:block;width:${dot}px;height:${dot}px;border-radius:2px;background:${c.d};opacity:.3"></span>`
156
+ ).join("");
157
+ const ic = R(rail * 0.46);
158
+ const railHtml = rail ? `<div style="width:${rail}px;flex:none;display:flex;flex-direction:column;align-items:center;gap:${R(rail * 0.3)}px;padding-top:${R(rail * 0.15)}px">` + [0.6, 0.28, 0.28].map(
159
+ (op) => `<span style="display:block;width:${ic}px;height:${ic}px;border-radius:${R(ic * 0.28)}px;background:${c.d};opacity:${op}"></span>`
160
+ ).join("") + `</div>` : "";
161
+ const html = `<div style="width:${w}px;height:${h}px;display:flex;flex-direction:column;border-radius:${rr}px;background:${c.f};box-shadow:${shadow};overflow:hidden"><div style="height:${bar}px;flex:none;display:grid;grid-template-columns:${lw}px minmax(0,1fr) ${lw}px;align-items:center;gap:${pad}px;padding:0 ${pad}px"><div style="display:flex;gap:${gap}px;align-items:center">${lights(T, dot, gap, c.d)}</div><div style="display:flex;justify-content:center;min-width:0">${title}</div><div style="display:flex;gap:${gap}px;justify-content:flex-end">${right}</div></div><div style="display:flex;padding:0 ${rim}px 0 ${rail ? 0 : rim}px">${railHtml}${mediaSurface(o, mw, mh, "4px")}</div><div style="height:${foot}px;flex:none"></div></div>`;
162
+ return { html, h };
163
+ },
164
+ caption(o, k) {
165
+ const { c, w, T, ratio, copy, font, rr, shadow, ring } = k;
166
+ const strip = R(clamp(w * 0.13, 30, 46));
167
+ const mh = R(w * ratio[1] / ratio[0]);
168
+ const h = mh + strip;
169
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length];
170
+ const html = `<div style="width:${w}px;height:${h}px;border-radius:${rr}px;box-shadow:0 0 0 ${ring}px ${c.f},${shadow};overflow:hidden;display:flex;flex-direction:column;background:${c.f}">${mediaSurface(o, w, mh, "0")}<div style="height:${strip}px;flex:none;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:0 ${R(strip * 0.38)}px;color:${c.d}"><span style="font:800 ${R(strip * 0.42)}px/1 ${font};letter-spacing:-0.01em;white-space:nowrap">${cap[0]}</span>` + (T !== "S" ? `<span style="font:500 ${clamp(R(strip * 0.27), 9, 11)}px/1 ${MONO};letter-spacing:.12em;opacity:.7;white-space:nowrap">${cap[1]}</span>` : "") + `</div></div>`;
171
+ return { html, h };
172
+ },
173
+ phone(o, k) {
174
+ const { c, w, shadow } = k;
175
+ const b = R(clamp(w * 0.04, 5, 9));
176
+ const rad = R(w * 0.17);
177
+ const mw = w - b * 2;
178
+ const mh = R(mw * 19.5 / 9);
179
+ const h = mh + b * 2;
180
+ const iw = R(w * 0.3);
181
+ const ih = R(clamp(w * 0.07, 8, 16));
182
+ const hb = R(w * 0.34);
183
+ const btn = (side, top, hh) => `<span style="position:absolute;${side}:-3px;top:${R(h * top)}px;width:3px;height:${R(h * hh)}px;border-radius:2px;background:${c.f}"></span>`;
184
+ const html = `<div style="position:relative;width:${w}px;height:${h}px">${btn("left", 0.18, 0.07)}${btn("left", 0.28, 0.11)}${btn("right", 0.24, 0.13)}<div style="position:relative;width:${w}px;height:${h}px;border-radius:${rad}px;background:${c.f};padding:${b}px;box-sizing:border-box;box-shadow:${shadow}">${mediaSurface(o, mw, mh, `${rad - b}px`)}<span style="position:absolute;left:50%;top:${b + R(ih * 0.6)}px;width:${iw}px;height:${ih}px;margin-left:${-iw / 2}px;border-radius:${ih}px;background:${c.f}"></span><span style="position:absolute;left:50%;bottom:${b + R(ih * 0.5)}px;width:${hb}px;height:${Math.max(3, R(w * 0.02))}px;margin-left:${-hb / 2}px;border-radius:4px;background:${c.f};opacity:.85"></span></div></div>`;
185
+ return { html, h };
186
+ },
187
+ monitor(o, k) {
188
+ const { c, w } = k;
189
+ const ratio = k.ratio[1] > k.ratio[0] ? [16, 10] : k.ratio;
190
+ const b = R(clamp(w * 0.025, 5, 10));
191
+ const chin = R(clamp(w * 0.055, 12, 20));
192
+ const mw = w - b * 2;
193
+ const mh = R(mw * ratio[1] / ratio[0]);
194
+ const nw = R(w * 0.12);
195
+ const nh = R(w * 0.09);
196
+ const bw = R(w * 0.38);
197
+ const bh = R(clamp(w * 0.022, 5, 9));
198
+ const h = b + mh + chin + nh + bh;
199
+ const html = `<div style="width:${w}px;height:${h}px;display:flex;flex-direction:column;align-items:center;filter:drop-shadow(0 ${R(w * 0.03)}px ${R(w * 0.05)}px rgba(0,0,0,.3))"><div style="width:${w}px;border-radius:${R(clamp(w * 0.03, 6, 12))}px;background:${c.f};padding:${b}px ${b}px 0;box-sizing:border-box">${mediaSurface(o, mw, mh, "3px")}<div style="height:${chin}px;display:flex;align-items:center;justify-content:center"><span style="width:${R(chin * 0.32)}px;height:${R(chin * 0.32)}px;border-radius:50%;background:${c.d};opacity:.35"></span></div></div><div style="width:${nw}px;height:${nh}px;flex:none;background:${c.f};clip-path:polygon(18% 0,82% 0,100% 100%,0 100%)"></div><div style="width:${bw}px;height:${bh}px;flex:none;border-radius:${bh}px;background:${c.f}"></div></div>`;
200
+ return { html, h };
201
+ },
202
+ polaroid(o, k) {
203
+ const { c, w, T, copy, shadow } = k;
204
+ const p = R(w * 0.06);
205
+ const bot = R(w * 0.24);
206
+ const mw = w - p * 2;
207
+ const mh = mw;
208
+ const h = p + mh + bot;
209
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length][0];
210
+ const tw = R(w * 0.36);
211
+ const th = R(clamp(w * 0.1, 14, 30));
212
+ const html = `<div style="position:relative;width:${w}px;height:${h}px"><div style="width:${w}px;height:${h}px;background:${c.f};border-radius:3px;padding:${p}px ${p}px 0;box-sizing:border-box;box-shadow:${shadow}">${mediaSurface(o, mw, mh, "1px")}<div style="height:${bot}px;display:flex;align-items:center;justify-content:center">` + (T === "S" && w < 180 ? "" : `<span style="font:italic 400 ${R(bot * 0.44)}px/1 'Instrument Serif',Georgia,serif;color:${c.d};opacity:.85">${cap}</span>`) + `</div></div><span style="position:absolute;left:50%;top:${-R(th * 0.45)}px;width:${tw}px;height:${th}px;margin-left:${-tw / 2}px;transform:rotate(-4deg);background:${hexA(c.d, 0.22)}"></span></div>`;
213
+ return { html, h };
214
+ },
215
+ stream(o, k) {
216
+ const { c, w, T, copy, font, rr, ring, shadow } = k;
217
+ const mh = R(w * k.ratio[1] / k.ratio[0]);
218
+ const h = mh;
219
+ const p = Math.max(8, R(w * 0.04));
220
+ const fs = clamp(R(w * 0.03), 9, 12);
221
+ const st = copy.stream ?? [copy.title, copy.caps[(o.idx ?? 0) % copy.caps.length][1]];
222
+ const live = `<div style="position:absolute;left:${p}px;top:${p}px;display:flex;align-items:center;gap:${R(fs * 0.5)}px;background:${c.f};color:${c.d};border-radius:4px;padding:${R(fs * 0.45)}px ${R(fs * 0.6)}px;font:500 ${fs}px/1 ${MONO};letter-spacing:.1em"><span style="width:${R(fs * 0.65)}px;height:${R(fs * 0.65)}px;border-radius:50%;background:${c.d}"></span>${T === "S" ? "" : "LIVE"}</div>`;
223
+ const viewers = T === "L" ? `<div style="position:absolute;right:${p}px;top:${p}px;background:${hexA(c.d, 0.75)};color:${c.f};border-radius:4px;padding:${R(fs * 0.45)}px ${R(fs * 0.6)}px;font:500 ${fs}px/1 ${MONO};letter-spacing:.06em">12.4K WATCHING</div>` : "";
224
+ const lh = R(clamp(w * 0.085, 18, 32));
225
+ const lower = T === "S" ? "" : `<div style="position:absolute;left:${p}px;bottom:${p}px;right:${R(w * 0.22)}px;height:${lh}px;display:flex"><div style="flex:1;min-width:0;background:${c.f};color:${c.d};display:flex;align-items:center;padding:0 ${R(lh * 0.4)}px;font:800 ${R(lh * 0.44)}px/1 ${font};white-space:nowrap;overflow:hidden">${T === "L" ? st[0] : ""}</div><div style="background:${c.d};color:${c.f};display:flex;align-items:center;padding:0 ${R(lh * 0.35)}px;font:500 ${R(lh * 0.42)}px/1 ${MONO};white-space:nowrap">${st[1]}</div></div>`;
226
+ const html = `<div style="position:relative;width:${w}px;height:${h}px;border-radius:${rr}px;box-shadow:0 0 0 ${ring}px ${c.f},${shadow};overflow:hidden">${mediaSurface({ ...o, clip: false }, w, mh, `${rr}px`)}${live}${viewers}${lower}</div>`;
227
+ return { html, h };
228
+ },
229
+ post(o, k) {
230
+ const { c, w, T, copy, font, rr, shadow } = k;
231
+ const p = R(clamp(w * 0.045, 8, 14));
232
+ const av = R(clamp(w * 0.1, 18, 32));
233
+ const mw = w - p * 2;
234
+ const mh = R(mw * k.ratio[1] / k.ratio[0]);
235
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length];
236
+ const who = T === "L" ? `<div style="display:flex;flex-direction:column;gap:3px;min-width:0"><span style="font:800 ${R(av * 0.42)}px/1 ${font};color:${c.d};white-space:nowrap">${cap[0]}</span><span style="font:500 ${R(av * 0.3)}px/1 ${MONO};color:${c.d};opacity:.6;white-space:nowrap">@${cap[0].toLowerCase().replace(/[^a-z0-9]/g, "") || "user"}</span></div>` : `<div style="display:flex;flex-direction:column;gap:${R(av * 0.16)}px;flex:1"><span style="display:block;width:${T === "M" ? "46%" : "60%"};height:${R(av * 0.26)}px;border-radius:4px;background:${c.d};opacity:.55"></span>` + (T === "M" ? `<span style="display:block;width:30%;height:${R(av * 0.18)}px;border-radius:4px;background:${c.d};opacity:.3"></span>` : "") + `</div>`;
237
+ const head = `<div style="height:${av}px;display:flex;align-items:center;gap:${R(av * 0.35)}px;margin-bottom:${p}px"><span style="width:${av}px;height:${av}px;border-radius:50%;background:${c.d};flex:none"></span>${who}</div>`;
238
+ const ic = R(clamp(w * 0.055, 10, 18));
239
+ const icons = `<div style="display:flex;gap:${R(ic * 0.9)}px;align-items:center;height:${ic}px;margin-top:${p}px">` + [
240
+ "M12 21C5 16 2 12 2 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 10 2.5C22 12 19 16 12 21z",
241
+ "M4 4h16v12H9l-5 4z",
242
+ "M3 12l18-8-7 17-3-7z"
243
+ ].map(
244
+ (d) => `<svg width="${ic}" height="${ic}" viewBox="0 0 24 24" fill="none" stroke="${c.d}" stroke-width="2.6" stroke-linejoin="round" style="display:block"><path d="${d}"/></svg>`
245
+ ).join("") + `</div>`;
246
+ const h = p + av + p + mh + p + ic + p;
247
+ const html = `<div style="width:${w}px;height:${h}px;box-sizing:border-box;padding:${p}px;border-radius:${rr}px;background:${c.f};box-shadow:${shadow}">${head}${mediaSurface(o, mw, mh, `${R(rr * 0.6)}px`)}${icons}</div>`;
248
+ return { html, h };
249
+ },
250
+ circle(o, k) {
251
+ const { c, w, shadow } = k;
252
+ const rw = R(clamp(w * 0.035, 4, 8));
253
+ const dot = R(w * 0.2);
254
+ const mw = w - rw * 2;
255
+ const html = `<div style="position:relative;width:${w}px;height:${w}px">` + (w >= 170 ? `<div style="position:absolute;inset:${-R(w * 0.07)}px;border-radius:50%;border:2px dashed ${c.f};opacity:.5"></div>` : "") + `<div style="width:${w}px;height:${w}px;border-radius:50%;background:${c.f};padding:${rw}px;box-sizing:border-box;box-shadow:${shadow}">${mediaSurface({ ...o, clip: false }, mw, mw, "50%")}</div><span style="position:absolute;right:${R(w * 0.03)}px;bottom:${R(w * 0.03)}px;width:${dot}px;height:${dot}px;border-radius:50%;background:${c.f};display:flex;align-items:center;justify-content:center"><span style="width:${R(dot * 0.5)}px;height:${R(dot * 0.5)}px;border-radius:50%;background:${c.d}"></span></span></div>`;
256
+ return { html, h: w };
257
+ },
258
+ message(o, k) {
259
+ const { c, w, T } = k;
260
+ const p = R(clamp(w * 0.03, 5, 9));
261
+ const mw = w - p * 2;
262
+ const mh = R(mw * k.ratio[1] / k.ratio[0]);
263
+ const rad = R(clamp(w * 0.07, 12, 22));
264
+ const foot = T === "S" && w < 220 ? 0 : R(clamp(w * 0.075, 16, 24));
265
+ const h = p + mh + (foot || p);
266
+ const tail = R(clamp(w * 0.06, 10, 18));
267
+ const html = `<div style="position:relative;width:${w}px;height:${h}px;filter:drop-shadow(0 ${R(w * 0.025)}px ${R(w * 0.04)}px rgba(0,0,0,.3))"><div style="width:${w}px;height:${h}px;box-sizing:border-box;padding:${p}px ${p}px 0;border-radius:${rad}px ${rad}px ${rad}px 4px;background:${c.f}">${mediaSurface(o, mw, mh, `${rad - p}px`)}` + (foot ? `<div style="height:${foot}px;display:flex;align-items:center;justify-content:flex-end;gap:5px;font:500 ${R(foot * 0.5)}px/1 ${MONO};color:${c.d};opacity:.65">21:04 <span style="letter-spacing:-0.25em">\u2713\u2713</span></div>` : "") + `</div><svg width="${tail}" height="${tail}" viewBox="0 0 10 10" style="position:absolute;left:${-R(tail * 0.55)}px;bottom:0;display:block"><path d="M10 0v10H0c4-1 7-4 10-10z" fill="${c.f}"/></svg></div>`;
268
+ return { html, h };
269
+ },
270
+ slide(o, k) {
271
+ const { c, w, copy, shadow } = k;
272
+ const rad = R(w * 0.05);
273
+ const aw = R(w * 0.74);
274
+ const ah = R(aw * 0.75);
275
+ const top = R((w - ah) * 0.38);
276
+ const sx = R((w - aw) / 2);
277
+ const cap = copy.caps[(o.idx ?? 0) % copy.caps.length][0];
278
+ const html = `<div style="position:relative;width:${w}px;height:${w}px;border-radius:${rad}px;background:${c.f};box-shadow:${shadow}"><div style="position:absolute;left:${sx}px;top:${top}px;border-radius:${R(aw * 0.06)}px;overflow:hidden;box-shadow:0 0 0 2px ${hexA(c.d, 0.25)}">${mediaSurface({ ...o, clip: false }, aw, ah, `${R(aw * 0.06)}px`)}</div>` + (w >= 180 ? `<div style="position:absolute;left:${sx}px;right:${sx}px;bottom:${R(w * 0.065)}px;display:flex;justify-content:space-between;font:500 ${clamp(R(w * 0.045), 9, 12)}px/1 ${MONO};letter-spacing:.12em;color:${c.d};opacity:.7"><span>${cap.toUpperCase()}</span><span>${String((o.idx ?? 0) + 1).padStart(2, "0")}</span></div>` : "") + `<span style="position:absolute;left:${R(w * 0.06)}px;top:${R(w * 0.06)}px;width:${R(w * 0.04)}px;height:${R(w * 0.04)}px;border-radius:50%;background:${c.d};opacity:.3"></span></div>`;
279
+ return { html, h: w };
280
+ },
281
+ ticket(o, k) {
282
+ const { c, w, T, copy, font, rr } = k;
283
+ const sw = R(w * 0.28);
284
+ const mw = w - sw;
285
+ const ratio = k.ratio[1] > k.ratio[0] ? [4, 3] : k.ratio;
286
+ const mh = R(mw * ratio[1] / ratio[0]);
287
+ const h = mh;
288
+ const nr = R(clamp(w * 0.03, 6, 12));
289
+ const mask = `radial-gradient(circle at ${mw}px 0,transparent ${nr}px,#000 ${nr + 0.5}px) top/100% 51% no-repeat,radial-gradient(circle at ${mw}px 100%,transparent ${nr}px,#000 ${nr + 0.5}px) bottom/100% 51% no-repeat`;
290
+ const bars = [2, 1, 3, 1, 2, 1, 1, 3, 2, 1, 2, 3, 1, 2].map((bw) => `<span style="display:block;width:${bw}px;height:100%;background:${c.d}"></span>`).join("");
291
+ const fs = clamp(R(sw * 0.08), 8, 10);
292
+ const info = T === "L" ? `<div style="display:flex;flex-direction:column;gap:5px"><span style="font:500 ${fs}px/1 ${MONO};letter-spacing:.14em;color:${c.d};opacity:.65">ADMIT ONE</span><span style="font:800 ${R(sw * 0.13)}px/1.05 ${font};color:${c.d};letter-spacing:-0.01em">${copy.title}</span><span style="font:500 ${fs}px/1 ${MONO};letter-spacing:.12em;color:${c.d}">\u2116 00${(o.idx ?? 0) + 1}</span></div>` : "<div></div>";
293
+ const stub = `<div style="width:${sw}px;height:${h}px;flex:none;background:${c.f};border-left:2px dashed ${hexA(c.d, 0.35)};box-sizing:border-box;display:flex;flex-direction:column;justify-content:space-between;padding:${R(sw * 0.12)}px">${info}` + (T === "S" ? "" : `<div style="display:flex;gap:2px;height:${R(sw * 0.2)}px">${bars}</div>`) + `</div>`;
294
+ const html = `<div style="width:${w}px;height:${h}px;filter:drop-shadow(0 ${R(w * 0.025)}px ${R(w * 0.04)}px rgba(0,0,0,.3))"><div style="display:flex;width:${w}px;height:${h}px;border-radius:${rr}px;overflow:hidden;-webkit-mask:${mask};mask:${mask}">${mediaSurface({ ...o, clip: false }, mw, mh, "0")}${stub}</div></div>`;
295
+ return { html, h };
296
+ }
297
+ };
298
+ function frame(o) {
299
+ const kind = o.kind ?? "browser";
300
+ const c = o.c;
301
+ const w = R(o.w);
302
+ const T = tier(w);
303
+ const ratio = FORCE[kind] ?? o.ratio ?? [16, 9];
304
+ const copy = (o.pack ? COPY[o.pack] : void 0) ?? fallbackCopy;
305
+ const font = o.font ?? FONT;
306
+ const rr = R(clamp(w * 0.035, 8, 14));
307
+ const shadow = `0 ${R(w * 0.03)}px ${R(w * 0.08)}px rgba(0,0,0,.32)`;
308
+ const ring = R(clamp(w * 8e-3, 2, 3));
309
+ const ctx = { c, w, T, ratio, copy, font, rr, shadow, ring };
310
+ const build = BUILDERS[kind];
311
+ if (build) {
312
+ const { html: html2, h } = build(o, ctx);
313
+ return { html: html2, h, tier: T };
314
+ }
315
+ const mh = R(w * ratio[1] / ratio[0]);
316
+ const html = `<div style="width:${w}px;height:${mh}px;border-radius:${rr}px;box-shadow:${o.ring === false ? "" : `0 0 0 ${ring}px ${c.f},`}${shadow};overflow:hidden">${mediaSurface(o, w, mh, `${rr}px`)}</div>`;
317
+ return { html, h: mh, tier: T };
318
+ }
319
+
320
+ // src/index.ts
321
+ var PROBE_W = 320;
322
+ function windowShape(o) {
323
+ const kind = o.kind ?? "browser";
324
+ const probeColors = { f: "#000000", d: "#ffffff" };
325
+ const probe = frame({
326
+ ...o,
327
+ kind,
328
+ w: PROBE_W,
329
+ c: probeColors
330
+ });
331
+ const ar = PROBE_W / probe.h;
332
+ const def = {
333
+ id: o.id,
334
+ label: o.label ?? o.id,
335
+ kind: "obj",
336
+ ar,
337
+ scale: (o.scale ?? 3) * (SCALE[kind] ?? 1),
338
+ /**
339
+ * Builds at the particle's real width, not a reference one, so chrome
340
+ * picks the right tier — a 240px frame merges its controls into a single
341
+ * capsule, a 420px one shows a URL. Building at a fixed width and scaling
342
+ * would both overflow the particle box and freeze the chrome at one tier.
343
+ */
344
+ render: (c, _variant, width) => frame({ ...o, kind, w: width ?? PROBE_W, c }).html
345
+ };
346
+ return o.place === void 0 ? def : { ...def, place: o.place };
347
+ }
348
+ function image(o) {
349
+ return windowShape({ ...o, kind: "bare" });
350
+ }
351
+ function video(o) {
352
+ return windowShape({ ...o, kind: "bare", clip: o.clip ?? true });
353
+ }
354
+
355
+ export { COPY, KINDS, SCALE, frame, image, isVideo, mediaSurface, tier, video, windowShape };
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@usemote/media",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Window frames and media particles for Mote — photos, GIFs and video that float with the shapes.",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "George Heavenson",
8
+ "url": "https://hearvie.dev"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/oti-adjei/mote.git",
13
+ "directory": "packages/media"
14
+ },
15
+ "homepage": "https://mote.hearvie.dev",
16
+ "bugs": "https://github.com/oti-adjei/mote/issues",
17
+ "type": "module",
18
+ "main": "./dist/index.cjs",
19
+ "module": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js",
25
+ "require": "./dist/index.cjs"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "sideEffects": false,
34
+ "size-limit": [
35
+ {
36
+ "path": "dist/index.js",
37
+ "limit": "12 kB"
38
+ }
39
+ ],
40
+ "peerDependencies": {
41
+ "@usemote/core": "^0.1.1"
42
+ },
43
+ "devDependencies": {
44
+ "@usemote/core": "0.1.1",
45
+ "happy-dom": "^20.0.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsup",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit",
53
+ "test": "vitest run",
54
+ "size": "size-limit"
55
+ }
6
56
  }