feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,188 @@
/* BEGIN USAGE */
// Android.jsx — Simplified Android (Material 3) device frame
// Status bar + content + gesture nav + keyboard.
// Based on Figma M3 spec. No dependencies, no image assets.
// Exports (to window): AndroidDevice, AndroidStatusBar, AndroidListItem, AndroidNavBar, AndroidKeyboard
//
// Usage — wrap your screen content in <AndroidDevice> to get the bezel, status
// bar and gesture nav (props: width=412, height=892, dark, keyboard):
//
// <AndroidDevice>
// ...your screen content...
// </AndroidDevice>
// <AndroidDevice dark keyboard>…</AndroidDevice>
// <AndroidDevice width={360} height={800}>…</AndroidDevice> // smaller device size
/* END USAGE */
const MD_C = {
surface: '#f4fbf8',
surfaceVariant: '#dae5e1',
inverseOnSurface: '#ecf2ef',
secondaryContainer: '#cde8e1',
primaryFixedDim: '#83d5c6',
onSurface: '#171d1b',
onSurfaceVar: '#49454f',
onPrimaryContainer: '#00201c',
primary: '#006a60',
frameBorder: 'rgba(116,119,117,0.5)',
};
// ─────────────────────────────────────────────────────────────
// Status bar (time left, wifi/cell/battery right)
// ─────────────────────────────────────────────────────────────
function AndroidStatusBar({ dark = false }) {
const c = dark ? '#fff' : MD_C.onSurface;
return (
<div style={{
height: 40, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 16px',
position: 'relative',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{/* time left */}
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
</div>
{/* camera punch-hole (center) */}
<div style={{
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
}} />
{/* status icons right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', paddingRight: 2 }}>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
</svg>
</div>
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// List item (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidListItem({ headline, supporting, leading }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{leading && (
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: MD_C.primary, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 500, flexShrink: 0,
}}>{leading}</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
{supporting && (
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
)}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Gesture nav bar (pill)
// ─────────────────────────────────────────────────────────────
function AndroidNavBar({ dark = false }) {
return (
<div style={{
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 108, height: 4, borderRadius: 2,
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
}} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame — wraps everything
// ─────────────────────────────────────────────────────────────
function AndroidDevice({
children, width = 412, height = 892, dark = false,
keyboard = false,
}) {
return (
<div style={{
width, height, borderRadius: 18, overflow: 'hidden',
background: dark ? '#1d1b20' : MD_C.surface,
border: `8px solid ${MD_C.frameBorder}`,
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
}}>
<AndroidStatusBar dark={dark} />
<div style={{ flex: 1, overflow: 'auto' }}>
{children}
</div>
{keyboard && <AndroidKeyboard />}
<AndroidNavBar dark={dark} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — Gboard (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidKeyboard() {
let _k = 0;
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
<div key={_k++} style={{
height: 46, borderRadius: r, flex, minWidth: minW,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'Roboto, system-ui', fontSize: fs,
color: MD_C.onPrimaryContainer,
}}>{l}</div>
);
const row = (keys, style = {}) => (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
{keys.map(l => key(l))}
</div>
);
return (
<div style={{
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
{/* navbar spacer (icons omitted) */}
<div style={{ height: 44 }} />
{/* key rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
<div style={{ display: 'flex', gap: 6 }}>
{key('', { bg: MD_C.surfaceVariant })}
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
{['z','x','c','v','b','n','m'].map(l => key(l))}
</div>
{key('', { bg: MD_C.surfaceVariant })}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
{key(',', { bg: MD_C.surfaceVariant })}
{key('', { flex: 3, minW: 154 })}
{key('.', { bg: MD_C.surfaceVariant })}
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
</div>
</div>
</div>
);
}
Object.assign(window, {
AndroidDevice, AndroidStatusBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
});

View File

@ -0,0 +1,773 @@
/* BEGIN USAGE */
// animations.jsx
// Reusable animation starter: Stage, Timeline, Sprite, easing helpers.
// Exports (to window): Stage, Sprite, PlaybackBar, TextSprite, ImageSprite, RectSprite,
// useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp.
//
// Usage (in an HTML file that loads React + Babel):
//
// <Stage width={1280} height={720} duration={10} background="#f6f4ef">
// <MyScene />
// </Stage>
//
// <Stage> auto-scales to the viewport and provides the scrubber, play/pause,
// ←/→ seek, space, and 0-to-reset controls, and persists the playhead.
// Set the optional `poster` prop (seconds) to the moment your opening scene is
// fully composed — the product thumbnail freezes on that frame (default ~1s).
// Inside <Stage>, any child can call useTime() to read the current
// playhead (seconds). Or wrap content in <Sprite start={1} end={4}>...</Sprite>
// to only render during that window -- children receive a `localTime` and
// `progress` via the useSprite() hook. Use Easing + interpolate()/animate()
// for tweens; TextSprite / ImageSprite / RectSprite have built-in entry/exit.
// Build YOUR scenes by composing Sprites inside a Stage.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
// ── Easing functions (hand-rolled, Popmotion-style) ─────────────────────────
// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic).
const Easing = {
linear: (t) => t,
// Quad
easeInQuad: (t) => t * t,
easeOutQuad: (t) => t * (2 - t),
easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
// Cubic
easeInCubic: (t) => t * t * t,
easeOutCubic: (t) => (--t) * t * t + 1,
easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1),
// Quart
easeInQuart: (t) => t * t * t * t,
easeOutQuart: (t) => 1 - (--t) * t * t * t,
easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t),
// Expo
easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))),
easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)),
easeInOutExpo: (t) => {
if (t === 0) return 0;
if (t === 1) return 1;
if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10);
return 1 - 0.5 * Math.pow(2, -20 * t + 10);
},
// Sine
easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2),
easeOutSine: (t) => Math.sin((t * Math.PI) / 2),
easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2,
// Back (overshoot)
easeOutBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
},
easeInBack: (t) => {
const c1 = 1.70158, c3 = c1 + 1;
return c3 * t * t * t - c1 * t * t;
},
easeInOutBack: (t) => {
const c1 = 1.70158, c2 = c1 * 1.525;
return t < 0.5
? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2
: (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2;
},
// Elastic
easeOutElastic: (t) => {
const c4 = (2 * Math.PI) / 3;
if (t === 0) return 0;
if (t === 1) return 1;
return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
},
};
// ── Core interpolation helpers ──────────────────────────────────────────────
// Clamp a value to [min, max]
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t)
// Popmotion-style: linearly maps t across input keyframes to output values,
// with optional easing per segment (single fn or array of fns).
function interpolate(input, output, ease = Easing.linear) {
return (t) => {
if (t <= input[0]) return output[0];
if (t >= input[input.length - 1]) return output[output.length - 1];
for (let i = 0; i < input.length - 1; i++) {
if (t >= input[i] && t <= input[i + 1]) {
const span = input[i + 1] - input[i];
const local = span === 0 ? 0 : (t - input[i]) / span;
const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease;
const eased = easeFn(local);
return output[i] + (output[i + 1] - output[i]) * eased;
}
}
return output[output.length - 1];
};
}
// animate({from, to, start, end, ease})(t) — simpler single-segment tween.
// Returns `from` before `start`, `to` after `end`.
function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) {
return (t) => {
if (t <= start) return from;
if (t >= end) return to;
const local = (t - start) / (end - start);
return from + (to - from) * ease(local);
};
}
// ── Timeline context ────────────────────────────────────────────────────────
const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false });
const useTime = () => React.useContext(TimelineContext).time;
const useTimeline = () => React.useContext(TimelineContext);
// ── Sprite ──────────────────────────────────────────────────────────────────
// Renders children only when the playhead is inside [start, end]. Provides
// a sub-context with `localTime` (seconds since start) and `progress` (0..1).
//
// <Sprite start={2} end={5}>
// {({ localTime, progress }) => <Thing x={progress * 100} />}
// </Sprite>
//
// Or as a plain wrapper — children can call useSprite() themselves.
const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 });
const useSprite = () => React.useContext(SpriteContext);
function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) {
const { time } = useTimeline();
const visible = time >= start && time <= end;
if (!visible && !keepMounted) return null;
const duration = end - start;
const localTime = Math.max(0, time - start);
const progress = duration > 0 && isFinite(duration)
? clamp(localTime / duration, 0, 1)
: 0;
const value = { localTime, progress, duration, visible };
return (
<SpriteContext.Provider value={value}>
{typeof children === 'function' ? children(value) : children}
</SpriteContext.Provider>
);
}
// ── Sample sprite components ────────────────────────────────────────────────
// TextSprite: fades/slides text in on entry, holds, then fades out on exit.
// Props: text, x, y, size, color, font, entryDur, exitDur, align
function TextSprite({
text,
x = 0, y = 0,
size = 48,
color = '#111',
font = 'Inter, system-ui, sans-serif',
weight = 600,
entryDur = 0.45,
exitDur = 0.35,
entryEase = Easing.easeOutBack,
exitEase = Easing.easeInCubic,
align = 'left',
letterSpacing = '-0.01em',
}) {
const { localTime, duration } = useSprite();
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let ty = 0;
if (localTime < entryDur) {
const t = entryEase(clamp(localTime / entryDur, 0, 1));
opacity = t;
ty = (1 - t) * 16;
} else if (localTime > exitStart) {
const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
ty = -t * 8;
}
const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0';
return (
<div style={{
position: 'absolute',
left: x, top: y,
transform: `translate(${translateX}, ${ty}px)`,
opacity,
fontFamily: font,
fontSize: size,
fontWeight: weight,
color,
letterSpacing,
whiteSpace: 'pre',
lineHeight: 1.1,
willChange: 'transform, opacity',
}}>
{text}
</div>
);
}
// ImageSprite: scales + fades in; optional Ken Burns drift during hold.
function ImageSprite({
src,
x = 0, y = 0,
width = 400, height = 300,
entryDur = 0.6,
exitDur = 0.4,
kenBurns = false,
kenBurnsScale = 1.08,
radius = 12,
fit = 'cover',
placeholder = null, // {label: string} for striped placeholder
}) {
const { localTime, duration } = useSprite();
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let scale = 1;
if (localTime < entryDur) {
const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1));
opacity = t;
scale = 0.96 + 0.04 * t;
} else if (localTime > exitStart) {
const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t;
} else if (kenBurns) {
const holdSpan = exitStart - entryDur;
const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0;
scale = 1 + (kenBurnsScale - 1) * holdT;
}
const content = placeholder ? (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)',
color: '#6b6458',
fontFamily: 'JetBrains Mono, ui-monospace, monospace',
fontSize: 13,
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>
{placeholder.label || 'image'}
</div>
) : (
<img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} />
);
return (
<div style={{
position: 'absolute',
left: x, top: y,
width, height,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center',
borderRadius: radius,
overflow: 'hidden',
willChange: 'transform, opacity',
}}>
{content}
</div>
);
}
// RectSprite: simple rectangle that animates position/size/color via props.
// Useful demo primitive — takes a `render` fn for per-frame customization.
function RectSprite({
x = 0, y = 0,
width = 100, height = 100,
color = '#111',
radius = 8,
entryDur = 0.4,
exitDur = 0.3,
render, // optional: (ctx) => style overrides
}) {
const spriteCtx = useSprite();
const { localTime, duration } = spriteCtx;
const exitStart = Math.max(0, duration - exitDur);
let opacity = 1;
let scale = 1;
if (localTime < entryDur) {
const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1));
opacity = clamp(localTime / entryDur, 0, 1);
scale = 0.4 + 0.6 * t;
} else if (localTime > exitStart) {
const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1));
opacity = 1 - t;
scale = 1 - 0.15 * t;
}
const overrides = render ? render(spriteCtx) : {};
return (
<div style={{
position: 'absolute',
left: x, top: y,
width, height,
background: color,
borderRadius: radius,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center',
willChange: 'transform, opacity',
...overrides,
}} />
);
}
function Stage({
width = 1280,
height = 720,
duration = 10,
background = '#f6f4ef',
fps = 60,
loop = true,
autoplay = true,
poster = null,
persistKey = 'animstage',
children,
}) {
// Thumbnail capture mode: the host appends ?thumbnail=1 before screenshotting.
// Freeze on a representative still — the author-declared `poster` second, or
// ~1s as a fallback — paused, with the playback bar hidden, so the product
// thumbnail is a deterministic frame of the first composed scene.
const captureMode = typeof location !== 'undefined' && /[?&]thumbnail=/.test(location.search || '');
const [time, setTime] = React.useState(() => {
if (captureMode) return clamp(poster == null ? 1 : poster, 0, duration);
try {
const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0');
return isFinite(v) ? clamp(v, 0, duration) : 0;
} catch { return 0; }
});
const [playing, setPlaying] = React.useState(captureMode ? false : autoplay);
const [scale, setScale] = React.useState(1);
const stageRef = React.useRef(null);
const canvasRef = React.useRef(null);
const rafRef = React.useRef(null);
const lastTsRef = React.useRef(null);
// Persist playhead
React.useEffect(() => {
try { localStorage.setItem(persistKey + ':t', String(time)); } catch {}
}, [time, persistKey]);
// Auto-scale to fit viewport
React.useEffect(() => {
if (!stageRef.current) return;
const el = stageRef.current;
const measure = () => {
const barH = captureMode ? 0 : 44; // playback bar height (hidden in capture mode)
const s = Math.min(
el.clientWidth / width,
(el.clientHeight - barH) / height
);
setScale(Math.max(0.05, s));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, [width, height]);
// Animation loop
React.useEffect(() => {
if (!playing) {
lastTsRef.current = null;
return;
}
const step = (ts) => {
if (lastTsRef.current == null) lastTsRef.current = ts;
const dt = (ts - lastTsRef.current) / 1000;
lastTsRef.current = ts;
setTime((t) => {
let next = t + dt;
if (next >= duration) {
if (loop) next = next % duration;
else { next = duration; setPlaying(false); }
}
return next;
});
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
lastTsRef.current = null;
};
}, [playing, duration, loop]);
// Keyboard: space = play/pause, ← → = seek
React.useEffect(() => {
const onKey = (e) => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
if (e.code === 'Space') {
e.preventDefault();
setPlaying(p => !p);
} else if (e.code === 'ArrowLeft') {
setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration));
} else if (e.code === 'ArrowRight') {
setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration));
} else if (e.key === '0' || e.code === 'Home') {
setTime(0);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [duration]);
const ctxValue = React.useMemo(
() => ({ time, duration, playing, setTime, setPlaying }),
[time, duration, playing]
);
return (
<div
ref={stageRef}
style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column',
alignItems: 'center',
background: '#0a0a0a',
fontFamily: 'Inter, system-ui, sans-serif',
}}
>
{/* Canvas area — vertically centered in remaining space */}
<div style={{
flex: 1,
width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
minHeight: 0,
}}>
<div
ref={canvasRef}
style={{
width, height,
background,
position: 'relative',
transform: `scale(${scale})`,
transformOrigin: 'center',
flexShrink: 0,
boxShadow: '0 20px 60px rgba(0,0,0,0.4)',
overflow: 'hidden',
}}
>
<TimelineContext.Provider value={ctxValue}>
{children}
</TimelineContext.Provider>
</div>
</div>
{/* Playback bar — stacked below canvas, never overlapping. Hidden in
capture mode so the thumbnail is just the frame, no chrome. */}
{!captureMode && (
<PlaybackBar
time={time}
duration={duration}
playing={playing}
onPlayPause={() => setPlaying(p => !p)}
onReset={() => { setTime(0); }}
onSeek={(t) => setTime(t)}
/>
)}
</div>
);
}
// ── Playback bar ────────────────────────────────────────────────────────────
// Play/pause, return-to-begin, scrub track, time display.
// Uses fixed-width time fields so layout doesn't thrash.
function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
const [trackHover, setTrackHover] = React.useState(null); // { x, t } — px within track + hovered time
const posFromEvent = React.useCallback((e) => {
const rect = trackRef.current.getBoundingClientRect();
const x = clamp(e.clientX - rect.left, 0, rect.width);
const t = rect.width > 0 ? (x / rect.width) * duration : 0;
return { x, t };
}, [duration]);
const onTrackMove = (e) => {
if (!trackRef.current) return;
const { x, t } = posFromEvent(e);
setTrackHover({ x, t });
if (dragging) onSeek(t);
};
const onTrackLeave = () => {
if (!dragging) setTrackHover(null);
};
const onTrackDown = (e) => {
const { x, t } = posFromEvent(e);
setDragging(true);
setTrackHover({ x, t });
onSeek(t);
};
// Grab the knob in place: begin dragging without seeking (no jump).
// stopPropagation keeps the track's click-to-seek from also firing.
const onBallDown = (e) => {
e.stopPropagation();
setDragging(true);
setTrackHover(posFromEvent(e));
};
React.useEffect(() => {
if (!dragging) return;
const prevCursor = document.body.style.cursor;
document.body.style.cursor = 'grabbing'; // stays grabbing even if the pointer leaves the knob mid-drag
const onUp = () => {
setDragging(false);
setTrackHover(null);
};
const onMove = (e) => {
if (!trackRef.current) return;
const { x, t } = posFromEvent(e);
setTrackHover({ x, t });
onSeek(t);
};
window.addEventListener('mouseup', onUp);
window.addEventListener('mousemove', onMove);
return () => {
window.removeEventListener('mouseup', onUp);
window.removeEventListener('mousemove', onMove);
document.body.style.cursor = prevCursor;
};
}, [dragging, posFromEvent, onSeek]);
const pct = duration > 0 ? (time / duration) * 100 : 0;
const fmt = (t) => {
const total = Math.max(0, t);
const m = Math.floor(total / 60);
const s = Math.floor(total % 60);
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
};
const numFont = '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif';
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px',
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.30) 0%, rgba(0, 0, 0, 0.00) 100%)',
width: '100%',
color: '#fff',
fontFamily: numFont,
userSelect: 'none',
flexShrink: 0,
boxSizing: 'border-box',
}}>
{/* Play / pause — bare white triangle, no button chrome */}
<IconButton onClick={onPlayPause} tooltip={playing ? '暂停' : '播放'}>
{playing ? (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33333 1.33398C2.59695 1.33398 2 1.93094 2 2.66732V13.334C2 14.0704 2.59695 14.6673 3.33333 14.6673H4.66667C5.40305 14.6673 6 14.0704 6 13.334V2.66732C6 1.93094 5.40305 1.33398 4.66667 1.33398H3.33333Z" fill="currentColor"/>
<path d="M11.3333 1.33398C10.597 1.33398 10 1.93094 10 2.66732V13.334C10 14.0704 10.597 14.6673 11.3333 14.6673H12.6667C13.403 14.6673 14 14.0704 14 13.334V2.66732C14 1.93094 13.403 1.33398 12.6667 1.33398H11.3333Z" fill="currentColor"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.0489 9.13127C14.873 8.60116 14.873 7.39754 14.0489 6.86743L4.74461 0.882617C3.84764 0.305661 2.66699 0.948902 2.66699 2.01454V13.9842C2.66699 15.0498 3.84764 15.693 4.74461 15.1161L14.0489 9.13127Z" fill="currentColor"/>
</svg>
)}
</IconButton>
{/* Current time */}
<div style={{
fontFamily: numFont,
fontSize: 14,
fontWeight: 400,
fontVariantNumeric: 'tabular-nums',
color: '#fff',
minWidth: 40,
textAlign: 'center'
}}>
{fmt(time)}
</div>
{/* Scrub track — white fill on translucent-white rail + draggable knob */}
<div
ref={trackRef}
onMouseMove={onTrackMove}
onMouseLeave={onTrackLeave}
onMouseDown={onTrackDown}
style={{
flex: 1,
height: 20,
position: 'relative',
cursor: 'pointer',
display: 'flex', alignItems: 'center',
}}
>
<div style={{
position: 'absolute',
left: 0, right: 0, height: 4,
background: 'rgba(255,255,255,0.6)',
borderRadius: 2,
}}/>
<div style={{
position: 'absolute',
left: 0, width: `${pct}%`, height: 4,
background: 'rgba(255,255,255,0.9)',
borderRadius: 2,
}}/>
{/* Progress knob — outer div is an enlarged transparent hit area for easier grabbing */}
<div
onMouseDown={onBallDown}
style={{
position: 'absolute',
left: `${pct}%`, top: '50%',
transform: 'translate(-50%, -50%)',
width: 20, height: 20,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: dragging ? 'grabbing' : 'grab',
zIndex: 5,
}}
>
<div style={{
width: dragging ? 14 : 12,
height: dragging ? 14 : 12,
background: '#fff',
borderRadius: '50%',
border: '0.5px solid #D2D5D8',
boxShadow: '0 2px 6px rgba(0,0,0,0.35)',
transition: 'width 100ms, height 100ms',
}}/>
</div>
{trackHover && (
<div style={{
position: 'absolute',
left: trackHover.x,
bottom: '100%',
transform: 'translateX(-50%)',
marginBottom: 2,
pointerEvents: 'none',
zIndex: 10,
}}>
<TooltipBubble text={fmt(trackHover.t)} />
</div>
)}
</div>
{/* Duration — dimmed */}
<div style={{
fontFamily: numFont,
fontSize: 14,
fontWeight: 400,
fontVariantNumeric: 'tabular-nums',
color: 'rgba(255,255,255,0.6)',
minWidth: 40,
textAlign: 'center',
}}>
{fmt(duration)}
</div>
</div>
);
}
function IconButton({ children, onClick, tooltip }) {
const [hover, setHover] = React.useState(false);
return (
<button
onClick={onClick}
aria-label={tooltip}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: 'relative',
width: 24, height: 24,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: hover ? 'rgba(255,255,255,0.1)' : 'transparent',
border: 'none',
borderRadius: 6,
color: '#fff',
cursor: 'pointer',
padding: 0,
transition: 'background 120ms',
}}
>
{children}
{tooltip && (
<div style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginBottom: 8,
pointerEvents: 'none',
opacity: hover ? 1 : 0,
transition: 'opacity 120ms',
zIndex: 10,
}}>
<TooltipBubble text={tooltip} />
</div>
)}
</button>
);
}
// ── Tooltip bubble ────────────────────────────────────────────────────────────
// Dark rounded bubble with a downward tail. Positioning is up to the caller.
function TooltipBubble({ text }) {
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<div style={{
background: '#1F2329',
color: '#fff',
fontSize: 12,
lineHeight: '16px',
padding: '6px 12px',
borderRadius: 6,
whiteSpace: 'nowrap',
fontFamily: '"PingFang SC", -apple-system, BlinkMacSystemFont, system-ui, sans-serif',
fontVariantNumeric: 'tabular-nums',
boxShadow: '0 4px 8px -8px rgba(0, 0, 0, 0.06), 0 6px 12px 0 rgba(0, 0, 0, 0.04), 0 8px 24px 8px rgba(0, 0, 0, 0.04)',
}}>
{text}
</div>
<div style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translate(-50%, -50%) rotate(45deg)',
width: 9, height: 9,
borderRadius: '0 0 3px 0',
background: '#1F2329',
}}/>
</div>
);
}
Object.assign(window, {
Easing, interpolate, animate, clamp,
TimelineContext, useTime, useTimeline,
Sprite, SpriteContext, useSprite,
TextSprite, ImageSprite, RectSprite,
Stage, PlaybackBar,
});

View File

@ -0,0 +1,122 @@
/* BEGIN USAGE */
// Chrome.jsx — Simplified Chrome browser window (dark theme, macOS)
// No dependencies, no image assets. All inline styles + inline SVG.
// Exports (to window): ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights
//
// Usage — wrap your page content in <ChromeWindow> to get the tab bar + URL bar:
//
// <ChromeWindow width={1100} height={680} url="acme.design/pricing">
// ...your page content...
// </ChromeWindow>
/* END USAGE */
const CHROME_C = {
barBg: '#202124',
tabBg: '#35363a',
text: '#e8eaed',
dim: '#9aa0a6',
urlBg: '#282a2d',
};
function ChromeTrafficLights() {
return (
<div style={{ display: 'flex', gap: 8, padding: '0 14px' }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#ff5f57' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#febc2e' }} />
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#28c840' }} />
</div>
);
}
// Single tab (active has curved scoops)
function ChromeTab({ title = 'New Tab', active = false }) {
const curve = (flip) => (
<svg width="8" height="10" viewBox="0 0 8 10"
style={{ position: 'absolute', bottom: 0, [flip ? 'right' : 'left']: -8, transform: flip ? 'scaleX(-1)' : 'none' }}>
<path d="M0 10C2 9 6 8 8 0V10H0Z" fill={CHROME_C.tabBg}/>
</svg>
);
return (
<div style={{
position: 'relative', height: 34, alignSelf: 'flex-end',
padding: '0 12px', display: 'flex', alignItems: 'center', gap: 8,
background: active ? CHROME_C.tabBg : 'transparent',
borderRadius: '8px 8px 0 0', minWidth: 120, maxWidth: 220,
fontFamily: 'system-ui, sans-serif', fontSize: 12,
color: active ? CHROME_C.text : CHROME_C.dim,
}}>
{active && curve(false)}
{active && curve(true)}
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#5f6368', flexShrink: 0 }} />
<span style={{ flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{title}</span>
</div>
);
}
function ChromeTabBar({ tabs = [{ title: 'New Tab' }], activeIndex = 0 }) {
return (
<div style={{
display: 'flex', alignItems: 'center', height: 44,
background: CHROME_C.barBg, paddingRight: 8,
}}>
<ChromeTrafficLights />
<div style={{ display: 'flex', alignItems: 'flex-end', height: '100%', paddingLeft: 4, flex: 1 }}>
{tabs.map((t, i) => <ChromeTab key={i} title={t.title} active={i === activeIndex} />)}
</div>
</div>
);
}
function ChromeToolbar({ url = 'example.com' }) {
const iconDot = (
<div style={{
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
</div>
);
return (
<div style={{
height: 40, background: CHROME_C.tabBg,
display: 'flex', alignItems: 'center', gap: 4, padding: '0 8px',
}}>
{iconDot}
{/* url bar */}
<div style={{
flex: 1, height: 30, borderRadius: 15, background: CHROME_C.urlBg,
display: 'flex', alignItems: 'center', gap: 8, padding: '0 14px',
margin: '0 6px',
}}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: CHROME_C.dim, opacity: 0.4 }} />
<span style={{
flex: 1, color: CHROME_C.text, fontSize: 13,
fontFamily: 'system-ui, sans-serif',
}}>{url}</span>
</div>
{iconDot}
</div>
);
}
function ChromeWindow({
tabs = [{ title: 'New Tab' }], activeIndex = 0, url = 'example.com',
width = 900, height = 600, children,
}) {
return (
<div style={{
width, height, borderRadius: 10, overflow: 'hidden',
border: '1px solid #DEE0E3',
display: 'flex', flexDirection: 'column', background: CHROME_C.tabBg,
}}>
<ChromeTabBar tabs={tabs} activeIndex={activeIndex} />
<ChromeToolbar url={url} />
<div style={{ flex: 1, background: '#fff', overflow: 'auto' }}>
{children}
</div>
</div>
);
}
Object.assign(window, {
ChromeWindow, ChromeTabBar, ChromeToolbar, ChromeTab, ChromeTrafficLights,
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,270 @@
/* BEGIN USAGE */
// iOS.jsx — Simplified iOS 26 (Liquid Glass) device frame
// Based on the iOS 26 UI Kit + Figma status bar spec. No assets, no deps.
// Exports (to window): IOSDevice, IOSStatusBar, IOSList, IOSListRow, IOSKeyboard
//
// Usage — wrap your screen content in <IOSDevice> to get the bezel, status bar
// and home indicator (props: width=402, height=874, dark, keyboard):
//
// <IOSDevice>
// ...your screen content...
// </IOSDevice>
// <IOSDevice dark keyboard>…</IOSDevice>
// <IOSDevice width={390} height={844}>…</IOSDevice> // smaller device size
//
// Safe areas — REQUIRED on every screen. The status bar (top) and home
// indicator (bottom) float OVER your content; inset it or it overlaps them.
// --ios-safe-top top inset (Dynamic Island + status bar)
// --ios-safe-bottom bottom inset (home indicator)
/* END USAGE */
// ─────────────────────────────────────────────────────────────
// Status bar
// ─────────────────────────────────────────────────────────────
function IOSStatusBar({ dark = false, time = '9:41' }) {
const c = dark ? '#fff' : '#000';
return (
<div style={{
display: 'flex', gap: 154, alignItems: 'center', justifyContent: 'center',
padding: '21px 24px 19px', boxSizing: 'border-box',
position: 'relative', zIndex: 20, width: '100%',
}}>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', paddingTop: 1.5 }}>
<span style={{
fontFamily: '-apple-system, "SF Pro", system-ui', fontWeight: 590,
fontSize: 17, lineHeight: '22px', color: c,
}}>{time}</span>
</div>
<div style={{ flex: 1, height: 22, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 7, paddingTop: 1, paddingRight: 1 }}>
<svg width="19" height="12" viewBox="0 0 19 12">
<rect x="0" y="7.5" width="3.2" height="4.5" rx="0.7" fill={c}/>
<rect x="4.8" y="5" width="3.2" height="7" rx="0.7" fill={c}/>
<rect x="9.6" y="2.5" width="3.2" height="9.5" rx="0.7" fill={c}/>
<rect x="14.4" y="0" width="3.2" height="12" rx="0.7" fill={c}/>
</svg>
<svg width="17" height="12" viewBox="0 0 17 12">
<path d="M8.5 3.2C10.8 3.2 12.9 4.1 14.4 5.6L15.5 4.5C13.7 2.7 11.2 1.5 8.5 1.5C5.8 1.5 3.3 2.7 1.5 4.5L2.6 5.6C4.1 4.1 6.2 3.2 8.5 3.2Z" fill={c}/>
<path d="M8.5 6.8C9.9 6.8 11.1 7.3 12 8.2L13.1 7.1C11.8 5.9 10.2 5.1 8.5 5.1C6.8 5.1 5.2 5.9 3.9 7.1L5 8.2C5.9 7.3 7.1 6.8 8.5 6.8Z" fill={c}/>
<circle cx="8.5" cy="10.5" r="1.5" fill={c}/>
</svg>
<svg width="27" height="13" viewBox="0 0 27 13">
<rect x="0.5" y="0.5" width="23" height="12" rx="3.5" stroke={c} strokeOpacity="0.35" fill="none"/>
<rect x="2" y="2" width="20" height="9" rx="2" fill={c}/>
<path d="M25 4.5V8.5C25.8 8.2 26.5 7.2 26.5 6.5C26.5 5.8 25.8 4.8 25 4.5Z" fill={c} fillOpacity="0.4"/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Grouped list (inset card, r:26) + row (52px)
// ─────────────────────────────────────────────────────────────
function IOSListRow({ title, detail, icon, chevron = true, isLast = false, dark = false }) {
const text = dark ? '#fff' : '#000';
const sec = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const ter = dark ? 'rgba(235,235,245,0.3)' : 'rgba(60,60,67,0.3)';
const sep = dark ? 'rgba(84,84,88,0.65)' : 'rgba(60,60,67,0.12)';
return (
<div style={{
display: 'flex', alignItems: 'center', minHeight: 52,
padding: '0 16px', position: 'relative',
fontFamily: '-apple-system, system-ui', fontSize: 17,
letterSpacing: -0.43,
}}>
{icon && (
<div style={{
width: 30, height: 30, borderRadius: 7, background: icon,
marginRight: 12, flexShrink: 0,
}} />
)}
<div style={{ flex: 1, color: text }}>{title}</div>
{detail && <span style={{ color: sec, marginRight: 6 }}>{detail}</span>}
{chevron && (
<svg width="8" height="14" viewBox="0 0 8 14" style={{ flexShrink: 0 }}>
<path d="M1 1l6 6-6 6" stroke={ter} strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
{!isLast && (
<div style={{
position: 'absolute', bottom: 0, right: 0,
left: icon ? 58 : 16, height: 0.5, background: sep,
}} />
)}
</div>
);
}
function IOSList({ header, children, dark = false }) {
const hc = dark ? 'rgba(235,235,245,0.6)' : 'rgba(60,60,67,0.6)';
const bg = dark ? '#1C1C1E' : '#fff';
return (
<div>
{header && (
<div style={{
fontFamily: '-apple-system, system-ui', fontSize: 13,
color: hc, textTransform: 'uppercase',
padding: '8px 36px 6px', letterSpacing: -0.08,
}}>{header}</div>
)}
<div style={{
background: bg, borderRadius: 26,
margin: '0 16px', overflow: 'hidden',
}}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame
// ─────────────────────────────────────────────────────────────
function IOSDevice({
children, width = 402, height = 874, dark = false,
keyboard = false,
}) {
return (
<div style={{
width, height, borderRadius: 48, overflow: 'hidden',
position: 'relative', background: dark ? '#000' : '#F2F2F7',
border: '1px solid #DEE0E3',
fontFamily: '-apple-system, system-ui, sans-serif',
WebkitFontSmoothing: 'antialiased',
'--ios-safe-top': '62px',
'--ios-safe-bottom': '34px',
}}>
{/* dynamic island */}
<div style={{
position: 'absolute', top: 11, left: '50%', transform: 'translateX(-50%)',
width: 126, height: 37, borderRadius: 24, background: '#000', zIndex: 50,
}} />
{/* status bar (absolute) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 10 }}>
<IOSStatusBar dark={dark} />
</div>
{/* content */}
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, overflow: 'auto' }}>{children}</div>
{keyboard && <IOSKeyboard dark={dark} />}
</div>
{/* home indicator — always on top */}
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 60,
height: 34, display: 'flex', justifyContent: 'center', alignItems: 'flex-end',
paddingBottom: 8, pointerEvents: 'none',
}}>
<div style={{
width: 139, height: 5, borderRadius: 100,
background: dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.25)',
}} />
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — iOS 26 liquid glass
// ─────────────────────────────────────────────────────────────
function IOSKeyboard({ dark = false }) {
const glyph = dark ? 'rgba(255,255,255,0.7)' : '#595959';
const sugg = dark ? 'rgba(255,255,255,0.6)' : '#333';
const keyBg = dark ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.85)';
// special-key icons
const icons = {
shift: <svg width="19" height="17" viewBox="0 0 19 17"><path d="M9.5 1L1 9.5h4.5V16h8V9.5H18L9.5 1z" fill={glyph}/></svg>,
del: <svg width="23" height="17" viewBox="0 0 23 17"><path d="M7 1h13a2 2 0 012 2v11a2 2 0 01-2 2H7l-6-7.5L7 1z" fill="none" stroke={glyph} strokeWidth="1.6" strokeLinejoin="round"/><path d="M10 5l7 7M17 5l-7 7" stroke={glyph} strokeWidth="1.6" strokeLinecap="round"/></svg>,
ret: <svg width="20" height="14" viewBox="0 0 20 14"><path d="M18 1v6H4m0 0l4-4M4 7l4 4" fill="none" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/></svg>,
};
const key = (content, { w, flex, ret, fs = 25, k } = {}) => (
<div key={k} style={{
height: 42, borderRadius: 8.5,
flex: flex ? 1 : undefined, width: w, minWidth: 0,
background: ret ? '#08f' : keyBg,
boxShadow: '0 1px 0 rgba(0,0,0,0.075)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: '-apple-system, "SF Compact", system-ui',
fontSize: fs, fontWeight: 458, color: ret ? '#fff' : glyph,
}}>{content}</div>
);
const row = (keys, pad = 0) => (
<div style={{ display: 'flex', gap: 6.5, justifyContent: 'center', padding: `0 ${pad}px` }}>
{keys.map(l => key(l, { flex: true, k: l }))}
</div>
);
return (
<div style={{
position: 'relative', zIndex: 15, borderRadius: 27, overflow: 'hidden',
padding: '11px 0 2px',
display: 'flex', flexDirection: 'column', alignItems: 'center',
boxShadow: dark
? '0 -2px 20px rgba(0,0,0,0.09)'
: '0 -1px 6px rgba(0,0,0,0.018), 0 -3px 20px rgba(0,0,0,0.012)',
}}>
{/* liquid glass bg — same recipe as nav pills */}
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
background: dark ? 'rgba(120,120,128,0.14)' : 'rgba(255,255,255,0.25)',
}} />
<div style={{
position: 'absolute', inset: 0, borderRadius: 27,
boxShadow: dark
? 'inset 1.5px 1.5px 1px rgba(255,255,255,0.15)'
: 'inset 1.5px 1.5px 1px rgba(255,255,255,0.7), inset -1px -1px 1px rgba(255,255,255,0.4)',
border: dark ? '0.5px solid rgba(255,255,255,0.15)' : '0.5px solid rgba(0,0,0,0.06)',
pointerEvents: 'none',
}} />
{/* autocorrect bar */}
<div style={{
display: 'flex', gap: 20, alignItems: 'center',
padding: '8px 22px 13px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{['"The"', 'the', 'to'].map((w, i) => (
<React.Fragment key={i}>
{i > 0 && <div style={{ width: 1, height: 25, background: '#ccc', opacity: 0.3 }} />}
<div style={{
flex: 1, textAlign: 'center',
fontFamily: '-apple-system, system-ui', fontSize: 17,
color: sugg, letterSpacing: -0.43, lineHeight: '22px',
}}>{w}</div>
</React.Fragment>
))}
</div>
{/* key layout */}
<div style={{
display: 'flex', flexDirection: 'column', gap: 13,
padding: '0 6.5px', width: '100%', boxSizing: 'border-box',
position: 'relative',
}}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], 20)}
<div style={{ display: 'flex', gap: 14.25, alignItems: 'center' }}>
{key(icons.shift, { w: 45, k: 'shift' })}
<div style={{ display: 'flex', gap: 6.5, flex: 1 }}>
{['z','x','c','v','b','n','m'].map(l => key(l, { flex: true, k: l }))}
</div>
{key(icons.del, { w: 45, k: 'del' })}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
{key('ABC', { w: 92.25, fs: 18, k: 'abc' })}
{key('', { flex: true, k: 'space' })}
{key(icons.ret, { w: 92.25, ret: true, k: 'ret' })}
</div>
</div>
{/* bottom spacer (emoji+mic area, icons omitted) */}
<div style={{ height: 56, width: '100%', position: 'relative' }} />
</div>
);
}
Object.assign(window, {
IOSDevice, IOSStatusBar, IOSList, IOSListRow, IOSKeyboard,
});

View File

@ -0,0 +1,197 @@
/* BEGIN USAGE */
// MacOS.jsx — Simplified macOS Tahoe (Liquid Glass) window
// Based on the macOS Tahoe UI Kit. No image assets, no dependencies.
// Exports (to window): MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader, MacToolbar, MacGlass, MacTrafficLights
//
// Usage — wrap your app content in <MacWindow> to get the window chrome
// (traffic lights + titlebar). Props: width, height, title, sidebar (pass a
// <MacSidebar> element); compose MacToolbar/MacGlass inside as needed:
//
// <MacWindow width={980} height={620} title="Documents"
// sidebar={<MacSidebar>…</MacSidebar>}>
// ...your app content...
// </MacWindow>
/* END USAGE */
const MAC_FONT = '-apple-system, BlinkMacSystemFont, "SF Pro", "Helvetica Neue", sans-serif';
// ─────────────────────────────────────────────────────────────
// Liquid glass primitive — blur + white tint + inset highlight
// ─────────────────────────────────────────────────────────────
function MacGlass({ children, radius = 296, dark = false, style = {} }) {
return (
<div style={{ position: 'relative', borderRadius: radius, ...style }}>
<div style={{
position: 'absolute', inset: 0, borderRadius: radius,
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.35)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
border: dark ? '0.5px solid rgba(255,255,255,0.12)' : '0.5px solid rgba(255,255,255,0.6)',
boxShadow: dark
? '0 8px 40px rgba(0,0,0,0.2)'
: '0 8px 40px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.4)',
}} />
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Traffic lights (14px, Tahoe colors)
// ─────────────────────────────────────────────────────────────
function MacTrafficLights({ style = {} }) {
const dot = (bg) => (
<div style={{
width: 14, height: 14, borderRadius: '50%', background: bg,
border: '0.5px solid rgba(0,0,0,0.1)',
}} />
);
return (
<div style={{ display: 'flex', gap: 9, alignItems: 'center', padding: 1, ...style }}>
{dot('#ff736a')}{dot('#febc2e')}{dot('#19c332')}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Toolbar — title + single glass pill icon
// ─────────────────────────────────────────────────────────────
function MacToolbar({ title = 'Folder' }) {
return (
<div style={{
display: 'flex', gap: 8, alignItems: 'center', padding: 8, flexShrink: 0,
}}>
{/* title */}
<div style={{
fontFamily: MAC_FONT, fontSize: 15, fontWeight: 700,
color: 'rgba(0,0,0,0.85)', whiteSpace: 'nowrap', paddingLeft: 8,
}}>{title}</div>
<div style={{ flex: 1 }} />
{/* single action */}
<MacGlass>
<div style={{
width: 36, height: 36, display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#4c4c4c', opacity: 0.4 }} />
</div>
</MacGlass>
{/* search */}
<MacGlass>
<div style={{
width: 140, height: 36, display: 'flex', alignItems: 'center',
gap: 6, padding: '0 12px',
}}>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="5.5" cy="5.5" r="4" stroke="#727272" strokeWidth="1.5"/>
<path d="M8.5 8.5l3 3" stroke="#727272" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<span style={{
fontFamily: MAC_FONT, fontSize: 13, fontWeight: 500, color: '#727272',
}}>Search</span>
</div>
</MacGlass>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Sidebar — frosted glass panel floating inside the window
// ─────────────────────────────────────────────────────────────
function MacSidebarItem({ label, selected = false }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
height: 24, padding: '4px 10px 4px 6px', margin: '0 10px',
borderRadius: 8, position: 'relative',
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 500,
}}>
{selected && (
<div style={{
position: 'absolute', inset: 0, borderRadius: 8,
background: 'rgba(0,0,0,0.11)', mixBlendMode: 'multiply',
}} />
)}
<div style={{
width: 14, height: 14, borderRadius: '50%',
background: selected ? '#007aff' : 'rgba(0,0,0,0.4)',
opacity: selected ? 1 : 0.5, flexShrink: 0, position: 'relative',
}} />
<span style={{ color: 'rgba(0,0,0,0.85)', position: 'relative' }}>{label}</span>
</div>
);
}
function MacSidebar({ children }) {
return (
<div style={{
width: 220, height: '100%', padding: 8, flexShrink: 0,
position: 'relative', display: 'flex', flexDirection: 'column',
}}>
{/* glass panel */}
<div style={{
position: 'absolute', inset: 8, borderRadius: 18,
background: 'rgba(210,225,245,0.45)',
backdropFilter: 'blur(50px) saturate(200%)',
WebkitBackdropFilter: 'blur(50px) saturate(200%)',
border: '0.5px solid rgba(255,255,255,0.5)',
boxShadow: '0 8px 40px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.35)',
}} />
{/* content */}
<div style={{
position: 'relative', zIndex: 1, padding: '10px 0',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
{/* window controls + sidebar toggle */}
<div style={{
height: 32, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 10px', marginBottom: 4,
}}>
<MacTrafficLights />
</div>
{children}
</div>
</div>
);
}
function MacSidebarHeader({ title }) {
return (
<div style={{
padding: '14px 18px 5px',
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 700,
color: 'rgba(0,0,0,0.5)',
}}>{title}</div>
);
}
// ─────────────────────────────────────────────────────────────
// Window — r:26, big shadow, sidebar + toolbar + content
// ─────────────────────────────────────────────────────────────
function MacWindow({
width = 900, height = 600, title = 'Folder',
sidebar, children,
}) {
return (
<div style={{
width, height, borderRadius: 26, overflow: 'hidden',
background: '#fff',
border: '1px solid #DEE0E3',
display: 'flex', position: 'relative',
fontFamily: MAC_FONT,
}}>
<MacSidebar>{sidebar}</MacSidebar>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<MacToolbar title={title} />
<div style={{ flex: 1, overflow: 'auto', padding: '4px 8px' }}>
{children}
</div>
</div>
</div>
);
}
Object.assign(window, {
MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader,
MacToolbar, MacGlass, MacTrafficLights,
});

View File

@ -0,0 +1,752 @@
/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for miaoda:tweaks:activate / miaoda:tweaks:deactivate,
// posts miaoda:tweaks:available / miaoda:tweaks:set-keys / miaoda:tweaks:dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// TWEAK_DEFAULTS must live inline in the HTML file — in a <script type="text/babel"> block,
// not in a separate .jsx/.js loaded via <script src>. That in-HTML block is the region the
// host rewrites when the user adjusts a tweak, so keep it wrapped in the /*EDITMODE-BEGIN*/ …
// /*EDITMODE-END*/ markers and the object between them valid JSON — double-quoted keys, no
// trailing commas, no comments or expressions — even after you rename the keys. Move it out
// of the HTML, strip the markers, or use a non-JSON body and tweak edits silently stop persisting.
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 25 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (miaoda:tweaks:set-keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: 'miaoda:tweaks:set-keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts miaoda:tweaks:dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes miaoda:tweaks:deactivate back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === 'miaoda:tweaks:activate') setOpen(true);
else if (t === 'miaoda:tweaks:deactivate') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: 'miaoda:tweaks:available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: 'miaoda:tweaks:dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-miaoda-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
// Opt out of DCViewport's transform so position:fixed works against the viewport.
TweaksPanel.dcOverlay = true;
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
// ── TweakSuggestionBar (flag-gated addon) ───────────────────────────────────
(function () {
const s = document.createElement('style');
s.textContent = `
@keyframes twk-blink{50%{opacity:0}}
@keyframes twk-fadein{from{opacity:0;transform:translateX(4px)}to{opacity:1;transform:none}}
.twk-sugg{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:8px;
background:rgba(0,0,0,.04);border:.5px solid rgba(0,0,0,.06);transition:all .15s}
.twk-sugg:focus-within{background:rgba(0,0,0,.06);border-color:rgba(0,0,0,.12)}
.twk-sugg-field{position:relative;flex:1;min-width:0}
.twk-sugg-field input{width:100%;height:20px;border:0;background:transparent;
font:inherit;outline:none;color:inherit}
.twk-sugg-ghost{position:absolute;inset:0;display:flex;align-items:center;
color:rgba(41,38,27,.42);pointer-events:none;white-space:nowrap;overflow:hidden}
.twk-sugg-ghost.hint{color:rgba(41,38,27,.28)}
.twk-sugg-caret{display:inline-block;width:1px;height:13px;margin-left:1px;
border-right:1.5px solid currentColor;opacity:.5;animation:twk-blink 1s step-end infinite}
.twk-sugg-ideas{appearance:none;border:0;background:transparent;font:inherit;
font-size:10.5px;font-weight:600;color:rgba(41,38,27,.6);cursor:default;padding:0 2px;
white-space:nowrap;animation:twk-fadein .25s ease}
.twk-sugg-ideas:hover{color:rgba(41,38,27,.85)}
.twk-sugg-ideas svg{color:#D97757}
.twk-sugg-send{appearance:none;border:0;height:20px;padding:0 8px;border-radius:5px;
background:#29261b;color:#fff;font:inherit;font-size:10px;font-weight:600;cursor:default}
`;
document.head.appendChild(s);
})();
const __twkSendChat = (text) =>
window.parent.postMessage({ type: 'miaoda:tweaks:chat', text }, '*');
const __TWK_SPARK_PATH = 'M18.3658 62.2435L36.7823 51.9165L37.0858 51.012L36.7823 50.5083H35.8716L32.7853 50.3206L22.2616 50.0389L13.1546 49.6634L4.30054 49.194L2.07438 48.7246L0 45.9551L0.202378 44.5938L2.07438 43.3264L4.75589 43.5611L10.6755 43.9836L19.5801 44.5938L26.0056 44.9693L35.568 45.9551H37.0858L37.2882 45.3448L36.7823 44.9693L36.3775 44.5938L27.1693 38.3507L17.2022 31.7789L11.9909 27.9767L9.20822 26.0522L7.79157 24.2684L7.18443 20.3254L9.71416 17.5089L13.1546 17.7436L14.0147 17.9783L17.5057 20.654L24.9431 26.4277L34.6573 33.5627L36.0739 34.7362L36.6444 34.3512L36.7317 34.079L36.0739 32.9994L30.8121 23.4704L25.1961 13.7537L22.6664 9.71675L22.0086 7.32277C21.7539 6.31812 21.6039 5.48695 21.6039 4.45938L24.4878 0.516349L26.1068 0L30.0026 0.516349L31.6216 1.92457L34.0502 7.46359L37.9459 16.1476L44.0173 27.9767L45.7881 31.4973L46.7494 34.7362L47.1036 35.722H47.7107V35.1587L48.2166 28.4931L49.1274 20.3254L50.0381 9.81063L50.3416 6.85336L51.8089 3.28586L54.7434 1.36128L57.0201 2.44092L58.8921 5.11655L58.6391 6.85336L57.5261 14.0822L55.3505 25.395L53.9338 32.9994H54.7434L55.7047 32.0136L59.5498 26.944L65.9753 18.8702L68.8086 15.6782L72.1479 12.1577L74.2729 10.4678H78.3204L81.2549 14.8802L79.9395 19.4335L75.7907 24.6909L72.3503 29.1503L67.4173 35.7593L64.3563 41.0732L64.6308 41.5116L65.3682 41.4487L76.499 39.0548L82.5198 37.9751L89.7042 36.7547L92.9423 38.2568L93.2964 39.8058L92.0316 42.9509L84.3412 44.8285L75.3354 46.6592L61.9245 49.8162L61.776 49.9356L61.9513 50.1956L67.9991 50.743L70.5795 50.8839H76.9038L88.6923 51.7757L91.7786 53.7942L93.6 56.282L93.2964 58.2066L88.5405 60.6006L82.1656 59.0985L67.2402 55.531L62.1302 54.2636H61.4218V54.6861L65.6718 58.8638L73.514 65.9049L83.2787 75.0114L83.7846 77.2646L82.5198 79.0483L81.2043 78.8606L72.6032 72.3827L69.264 69.4724L61.776 63.1354H61.2701V63.7926L62.9903 66.3274L72.1479 80.081L72.6032 84.3057L71.9455 85.667L69.5676 86.5119L66.9872 86.0425L61.5736 78.4851L56.0588 70.0357L51.6065 62.4313L51.0687 62.7708L48.419 91.0652L47.2048 92.5204L44.3715 93.6L41.9935 91.8162L40.7286 88.9059L41.9935 83.1322L43.5114 75.6217L44.7256 69.6602L45.8387 62.2435L46.5185 59.7659L46.4584 59.6001L45.9153 59.6914L40.3239 67.3601L31.824 78.8606L25.0949 86.0425L23.4759 86.6997L20.6932 85.2445L20.9462 82.6628L22.5146 80.3627L31.824 68.5336L37.44 61.1639L41.0595 56.9335L41.0243 56.3216L40.8245 56.3046L16.0891 72.4297L11.6874 72.993L9.76476 71.2092L10.0177 68.2989L10.9284 67.3601L18.3658 62.2435Z';
function ClaudeSpark({ size = 12 }) {
return (
<svg width={size} height={size} viewBox="0 0 94 94" fill="currentColor"
style={{ display: 'inline-block', verticalAlign: '-1px' }}>
<path d={__TWK_SPARK_PATH} />
</svg>
);
}
// Typewriter-cycles through `suggestions`. Clicking the field while a
// suggestion is animating freezes it as ghost text; Tab accepts it into the
// input. Enter posts miaoda:tweaks:chat (host drops the text into the chat
// composer for the user to send). After the cycle the static placeholder
// types in and "Ideas" appears — clicking asks for three more suggestions.
function TweakSuggestionBar({
suggestions = [],
placeholder = 'Describe a tweak…',
ideasPrompt = 'Suggest three more tweak ideas for this design and update the suggestions on TweakSuggestionBar.',
}) {
const [val, setVal] = React.useState('');
const [ghost, setGhost] = React.useState('');
const [focused, setFocused] = React.useState(false);
const inputRef = React.useRef(null);
const tw = useTwkTypewriter(suggestions, { placeholder, enabled: !val && !ghost && !focused });
const freeze = () => {
tw.markPlayed();
if (val || ghost) return;
const target = !tw.done ? suggestions[tw.idx] : '';
if (target) setGhost(target);
inputRef.current?.focus();
};
const submit = () => {
const v = (val || ghost).trim();
if (!v) return;
__twkSendChat(v);
setVal('');
setGhost('');
};
const onKeyDown = (e) => {
if (e.key === 'Tab' && ghost && !val) {
e.preventDefault();
setVal(ghost);
setGhost('');
} else if (e.key === 'Enter') {
e.preventDefault();
submit();
} else if (e.key === 'Escape') {
setGhost('');
}
};
const requestIdeas = () => __twkSendChat(ideasPrompt);
const showAnim = !val && !ghost && !focused && !tw.done;
const showStatic = !val && !ghost && !focused && tw.done;
return (
<div className="twk-sugg" onMouseDown={freeze}>
<div className="twk-sugg-field">
<input
ref={inputRef}
value={val}
placeholder={focused && !ghost ? placeholder : ''}
onChange={(e) => { setVal(e.target.value); setGhost(''); }}
onFocus={() => { setFocused(true); tw.markPlayed(); }}
onBlur={() => { setFocused(false); if (!val) setGhost(''); }}
onKeyDown={onKeyDown}
/>
{showAnim && (
<div className="twk-sugg-ghost">
{tw.text}<span className="twk-sugg-caret" />
</div>
)}
{showStatic && (
<div className="twk-sugg-ghost">
{tw.tail}{tw.tail.length < placeholder.length && <span className="twk-sugg-caret" />}
</div>
)}
{ghost && !val && (
<div className="twk-sugg-ghost hint">{ghost}</div>
)}
</div>
{val || ghost ? (
<button className="twk-sugg-send"
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
onClick={submit}>
Add
</button>
) : tw.done && !focused ? (
<button className="twk-sugg-ideas"
onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
onClick={requestIdeas}>
Ideas <ClaudeSpark />
</button>
) : null}
</div>
);
}
// Minimal type→pause→erase cycler. Plays once per unique `items` content per
// session — a reload from a tweak-value write skips straight to done; a new
// suggestion set (after "Ideas") gets a fresh animation.
function useTwkTypewriter(items, { placeholder, typeMs = 35, eraseMs = 22, pauseMs = 1800, enabled = true } = {}) {
const key = React.useMemo(() => '__twk_played:' + JSON.stringify(items), [items.join('\n')]);
const played = () => { try { return sessionStorage.getItem(key) === '1'; } catch { return false; } };
const [text, setText] = React.useState('');
const [tail, setTail] = React.useState(() => (items.length === 0 || played() ? placeholder : ''));
const [idx, setIdx] = React.useState(0);
const [done, setDone] = React.useState(() => items.length === 0 || played());
const phase = React.useRef('type');
const n = React.useRef(0);
const markPlayed = React.useCallback(() => {
try { sessionStorage.setItem(key, '1'); } catch {}
setDone(true);
}, [key]);
React.useEffect(() => {
const skip = items.length === 0 || played();
setText(''); setIdx(0);
setDone(skip);
setTail(skip ? placeholder : '');
phase.current = 'type'; n.current = 0;
}, [key]);
React.useEffect(() => {
if (done || !enabled) return;
const item = items[idx] ?? '';
let t;
const tick = () => {
if (phase.current === 'type') {
n.current++;
setText(item.slice(0, n.current));
if (n.current >= item.length) { phase.current = 'pause'; t = setTimeout(tick, pauseMs); }
else t = setTimeout(tick, typeMs + Math.random() * 20);
} else if (phase.current === 'pause') {
phase.current = 'erase'; t = setTimeout(tick, eraseMs);
} else {
n.current--;
setText(item.slice(0, n.current));
if (n.current <= 0) {
if (idx === items.length - 1) { markPlayed(); return; }
phase.current = 'type'; setIdx((i) => i + 1);
} else t = setTimeout(tick, eraseMs);
}
};
phase.current = 'type'; n.current = 0; setText('');
t = setTimeout(tick, 400);
return () => clearTimeout(t);
}, [idx, done, key, enabled, typeMs, eraseMs, pauseMs]);
React.useEffect(() => {
if (!done || tail === placeholder) return;
let i = 0;
const t = setInterval(() => {
i++; setTail(placeholder.slice(0, i));
if (i >= placeholder.length) clearInterval(t);
}, 28);
return () => clearInterval(t);
}, [done, placeholder]);
return { text, tail, idx, done, markPlayed };
}
Object.assign(window, { TweakSuggestionBar });