feat(ui): motion that answers the finger, and honours reduced-motion; release v6.2.0
The mobile menu becomes a surface you drag rather than one that appears and vanishes: it tracks the finger, carries its velocity when flicked, can be caught mid-flight, and resists past the edge instead of stopping dead. Buttons react on press rather than on release, and the browser's tap delay is gone — nothing here zooms, so the wait bought nothing. prefers-reduced-motion, prefers-reduced-transparency and prefers-contrast are now respected instead of ignored: looping decoration stops, transitions fade rather than slide, the chat jumps to the newest message, frosted panels turn solid and edges get drawn. What stops is the movement, never the information — recording indicators and other status stay. Also bumps the desktop download buttons to 0.5.0. The version lives in one constant per source file and tests/desktop-download-links.test.mjs fetches every generated URL, so the links are proven to serve a real asset rather than merely looking well-formed. Claude-Session: https://claude.ai/code/session_01ARFZ9G6P1e1B4w75kFaeNw
This commit is contained in:
+179
-9
@@ -24,6 +24,12 @@ import {
|
||||
import { GroupSession, GROUP_FRAMES, isGroupFrame, groupFrameType, decodeEnvelope } from './group/GroupSession.js';
|
||||
import { GROUP_LIMITS } from './group/groupCrypto.js';
|
||||
import { createGroupSender } from './group/groupSender.js';
|
||||
import { spring, snapTarget, rubberband, velocityTracker, prefersReducedMotion, SPRING } from './ui/motion.js';
|
||||
|
||||
// A programmatic smooth scroll is a full-viewport slide, which is exactly what
|
||||
// someone who asked for less motion does not want. The jump still happens and
|
||||
// still lands in the same place — it just stops travelling to get there.
|
||||
const scrollBehavior = () => (prefersReducedMotion() ? 'auto' : 'smooth');
|
||||
import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, GroupErrorModal, AddMembersModal } from './components/ui/GroupChat.jsx';
|
||||
|
||||
// ── Secure chat extras: code blocks, clipboard hygiene ──────────────
|
||||
@@ -1640,7 +1646,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
// /download/v0.3.0/SecureBit.Chat_0.1.0_x64-setup.exe, a file that never
|
||||
// existed, and the download 404s. A pinned tag keeps serving a real
|
||||
// installer instead.
|
||||
const SB_DESKTOP_VERSION = '0.3.0';
|
||||
const SB_DESKTOP_VERSION = '0.5.0';
|
||||
const SB_DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${SB_DESKTOP_VERSION}`;
|
||||
const DOWNLOADS = {
|
||||
mac: { name: 'macOS', format: '.dmg · Apple Silicon & Intel', icon: 'fab fa-apple', url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_x64.dmg` },
|
||||
@@ -1825,7 +1831,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
if (chatMessagesRef.current) {
|
||||
chatMessagesRef.current.scrollTo({
|
||||
top: chatMessagesRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: scrollBehavior()
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2221,7 +2227,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
if (chatMessagesRef.current) {
|
||||
chatMessagesRef.current.scrollTo({
|
||||
top: chatMessagesRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: scrollBehavior()
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2245,7 +2251,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
scrollToBottom();
|
||||
setShowScrollButton(false);
|
||||
} else if (chatMessagesRef.current) {
|
||||
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: 'smooth' });
|
||||
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: scrollBehavior() });
|
||||
setShowScrollButton(false);
|
||||
}
|
||||
};
|
||||
@@ -2572,6 +2578,163 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
const [editingId, setEditingId] = React.useState(null);
|
||||
const [draft, setDraft] = React.useState('');
|
||||
const [presenceOpen, setPresenceOpen] = React.useState(false);
|
||||
|
||||
// ── Mobile drawer: a gesture, not a toggle ───────────────────────
|
||||
// This used to be `display: none | block`. The drawer existed or it
|
||||
// did not; there was no in-between, no way to peek at it, and no way
|
||||
// to change your mind once it was moving. It is dragged now: the
|
||||
// panel is glued to the finger, resists past its own edge, and on
|
||||
// release the flick is projected forward to work out whether it was
|
||||
// being sent home or thrown away. Everything animates on a spring so
|
||||
// it can be caught again mid-flight and reversed without waiting.
|
||||
const scrimRef = React.useRef(null);
|
||||
const panelRef = React.useRef(null);
|
||||
const animRef = React.useRef(null);
|
||||
const dragRef = React.useRef(null);
|
||||
const offsetRef = React.useRef(0); // px; 0 = open, -width = closed
|
||||
const openRef = React.useRef(false); // where the panel is headed
|
||||
const clickGuardRef = React.useRef(false);
|
||||
const [drawerMounted, setDrawerMounted] = React.useState(!!drawerOpen);
|
||||
|
||||
const AXIS_LOCK = 10; // hysteresis before committing to a direction
|
||||
const FLICK = 220; // px/s above which velocity, not position, decides
|
||||
|
||||
const drawerWidth = () => (panelRef.current && panelRef.current.offsetWidth) || 292;
|
||||
|
||||
// Written straight to the DOM. Putting a 1:1 drag through React state
|
||||
// inserts a render between the finger and the pixels, which is exactly
|
||||
// the latency that makes direct manipulation stop feeling direct.
|
||||
const paintDrawer = (x) => {
|
||||
offsetRef.current = x;
|
||||
const w = drawerWidth();
|
||||
const progress = Math.min(1, Math.max(0, 1 + x / w));
|
||||
if (panelRef.current) panelRef.current.style.transform = 'translate3d(' + x.toFixed(2) + 'px,0,0)';
|
||||
if (scrimRef.current) {
|
||||
scrimRef.current.style.opacity = progress.toFixed(3);
|
||||
// The blur comes up with the panel rather than being switched
|
||||
// on at the end: the scrim should read as a material arriving,
|
||||
// not as a filter someone flipped.
|
||||
const blur = 'blur(' + (5 * progress).toFixed(2) + 'px)';
|
||||
scrimRef.current.style.backdropFilter = blur;
|
||||
scrimRef.current.style.webkitBackdropFilter = blur;
|
||||
}
|
||||
};
|
||||
|
||||
const settleDrawer = (to, velocity) => {
|
||||
if (animRef.current) animRef.current.stop();
|
||||
animRef.current = spring({
|
||||
from: offsetRef.current,
|
||||
to,
|
||||
velocity,
|
||||
// Overshoot has to be earned. A drawer that was flicked carries
|
||||
// momentum and should land with a little bounce; one that was
|
||||
// opened by tapping the burger has none, and wobbling would be
|
||||
// the interface talking to itself.
|
||||
damping: Math.abs(velocity) > 60 ? SPRING.drawer.damping : SPRING.move.damping,
|
||||
response: SPRING.drawer.response,
|
||||
restDelta: 0.5,
|
||||
restSpeed: 8,
|
||||
onUpdate: paintDrawer,
|
||||
onComplete: () => {
|
||||
paintDrawer(to);
|
||||
animRef.current = null;
|
||||
if (to !== 0) setDrawerMounted(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (drawerOpen && !drawerMounted) { setDrawerMounted(true); return; }
|
||||
if (!drawerMounted) return;
|
||||
if (drawerOpen === openRef.current) return; // already going there
|
||||
const w = drawerWidth();
|
||||
if (drawerOpen && !animRef.current && !dragRef.current) {
|
||||
// Parked closed: place it off the edge it will leave by, so the
|
||||
// way in and the way out are the same path.
|
||||
paintDrawer(-w);
|
||||
}
|
||||
openRef.current = drawerOpen;
|
||||
// Start from the live velocity, never from zero — reversing a moving
|
||||
// panel by resetting it to standstill is the "brick wall" the user
|
||||
// feels at exactly the moment they changed their mind.
|
||||
settleDrawer(drawerOpen ? 0 : -w, animRef.current ? animRef.current.velocity : 0);
|
||||
}, [drawerOpen, drawerMounted]);
|
||||
|
||||
React.useEffect(() => () => { if (animRef.current) animRef.current.stop(); }, []);
|
||||
|
||||
// Handlers live on the scrim alone; pointer events from the panel bubble
|
||||
// up to it, so one set covers both surfaces and can never double-fire.
|
||||
const drawerDown = (e) => {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
if (dragRef.current) return;
|
||||
// Cleared here rather than in the click handler: a drag that ends
|
||||
// outside the scrim produces no click at all, and a guard left
|
||||
// standing would swallow the next genuine tap-to-dismiss.
|
||||
clickGuardRef.current = false;
|
||||
dragRef.current = {
|
||||
id: e.pointerId,
|
||||
x0: e.clientX, y0: e.clientY,
|
||||
// Where they grabbed is where it stays. Re-centring the panel
|
||||
// under the finger breaks the illusion on the first frame.
|
||||
base: offsetRef.current,
|
||||
axis: null,
|
||||
vel: velocityTracker()
|
||||
};
|
||||
dragRef.current.vel.add(e.clientX, e.timeStamp || performance.now());
|
||||
};
|
||||
|
||||
const drawerMove = (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || e.pointerId !== d.id) return;
|
||||
const dx = e.clientX - d.x0;
|
||||
const dy = e.clientY - d.y0;
|
||||
if (!d.axis) {
|
||||
// Both gestures are tracked from the first move and the loser is
|
||||
// only cancelled once intent is unambiguous — a vertical scroll
|
||||
// through the chat list must never be stolen by the drawer.
|
||||
if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) return;
|
||||
if (Math.abs(dy) >= Math.abs(dx)) { dragRef.current = null; return; }
|
||||
d.axis = 'x';
|
||||
if (animRef.current) {
|
||||
// Take over from wherever it is on screen right now, not from
|
||||
// where the animation was headed.
|
||||
d.base = offsetRef.current;
|
||||
d.x0 = e.clientX;
|
||||
animRef.current.stop();
|
||||
animRef.current = null;
|
||||
}
|
||||
try { e.currentTarget.setPointerCapture(d.id); } catch (_) {}
|
||||
}
|
||||
d.vel.add(e.clientX, e.timeStamp || performance.now());
|
||||
const w = drawerWidth();
|
||||
let x = d.base + (e.clientX - d.x0);
|
||||
// Past the open edge there is nothing left to reveal. Resisting
|
||||
// reads as "responsive, but this is the end"; stopping dead reads
|
||||
// as the app having frozen.
|
||||
if (x > 0) x = rubberband(x, w);
|
||||
else if (x < -w) x = -w + rubberband(x + w, w);
|
||||
paintDrawer(x);
|
||||
};
|
||||
|
||||
const drawerUp = (e) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || (e.pointerId !== undefined && e.pointerId !== d.id)) return;
|
||||
dragRef.current = null;
|
||||
if (d.axis !== 'x') return; // a tap: let the click through
|
||||
clickGuardRef.current = true; // a drag: the scrim must not also fire
|
||||
const w = drawerWidth();
|
||||
const v = d.vel.get(e.timeStamp || performance.now());
|
||||
// Where the gesture was going, not where it happened to stop.
|
||||
const open = snapTarget(offsetRef.current, v, [-w, 0], { flick: FLICK }) === 0;
|
||||
openRef.current = open;
|
||||
settleDrawer(open ? 0 : -w, v);
|
||||
if (!open) onCloseDrawer();
|
||||
};
|
||||
|
||||
const drawerScrimClick = () => {
|
||||
if (clickGuardRef.current) { clickGuardRef.current = false; return; }
|
||||
onCloseDrawer();
|
||||
};
|
||||
const startEdit = (c) => (e) => { e.stopPropagation(); setEditingId(c.id); setDraft(c.name); };
|
||||
const commitEdit = () => { if (editingId) { onRename(editingId, draft); setEditingId(null); } };
|
||||
const editKey = (e) => {
|
||||
@@ -2836,12 +2999,19 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
'@media (max-width:768px){.sb-rename-btn{display:none !important;}}' } }),
|
||||
// Desktop rail
|
||||
h('aside', { key: 'rail', className: 'sb-rail', style: railStyle }, inner),
|
||||
// Mobile drawer overlay
|
||||
// Mobile drawer overlay. `touch-action: pan-y` hands vertical
|
||||
// scrolling back to the browser and keeps the horizontal axis for
|
||||
// us, so the chat list still scrolls with the drawer open.
|
||||
h('div', {
|
||||
key: 'drawer', className: 'sb-drawer-overlay',
|
||||
onClick: onCloseDrawer,
|
||||
style: { position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,8,0.6)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', display: drawerOpen ? 'block' : 'none' }
|
||||
}, h('aside', { className: 'sb-mobile-drawer', onClick: (e) => e.stopPropagation(), style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 'min(292px, 86vw)', display: 'flex', flexDirection: 'column', background: '#0c0c0e', borderRight: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 0 60px rgba(0,0,0,0.6)' } }, [
|
||||
ref: scrimRef,
|
||||
onClick: drawerScrimClick,
|
||||
onPointerDown: drawerDown,
|
||||
onPointerMove: drawerMove,
|
||||
onPointerUp: drawerUp,
|
||||
onPointerCancel: drawerUp,
|
||||
style: { position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,8,0.6)', backdropFilter: 'blur(0px)', WebkitBackdropFilter: 'blur(0px)', opacity: 0, display: drawerMounted ? 'block' : 'none', touchAction: 'pan-y', willChange: 'opacity' }
|
||||
}, h('aside', { className: 'sb-mobile-drawer', ref: panelRef, onClick: (e) => e.stopPropagation(), style: { position: 'absolute', left: 0, top: 0, bottom: 0, width: 'min(292px, 86vw)', display: 'flex', flexDirection: 'column', background: '#0c0c0e', borderRight: '1px solid rgba(255,255,255,0.06)', boxShadow: '0 0 60px rgba(0,0,0,0.6)', touchAction: 'pan-y', willChange: 'transform' } }, [
|
||||
// Explicit close button — the drawer's own header only has a
|
||||
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
||||
// no obvious way to dismiss it. This X closes the drawer reliably.
|
||||
@@ -3440,7 +3610,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
||||
if (container && container.scrollTo) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
behavior: scrollBehavior()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// The tag is pinned rather than using /releases/latest/download/: the filenames
|
||||
// carry the version, so a `latest` link 404s the moment a newer release exists,
|
||||
// whereas a pinned tag keeps serving a working (if older) installer.
|
||||
const DESKTOP_VERSION = '0.3.0';
|
||||
const DESKTOP_VERSION = '0.5.0';
|
||||
const DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${DESKTOP_VERSION}`;
|
||||
|
||||
const DownloadApps = () => {
|
||||
|
||||
@@ -538,12 +538,18 @@ const EnhancedMinimalHeader = ({
|
||||
// On the landing the header floats *over* the full-height hero (position
|
||||
// fixed), transparent at the top and blurred once scrolled. When connected it
|
||||
// falls back to the in-flow sticky bar.
|
||||
// The bar is a material, not a coloured strip: a big surface reads as a thick
|
||||
// one, so it gets a heavy blur, and the saturation bump is what stops a
|
||||
// blurred dark background from going flat grey. Blur and background are
|
||||
// animated together so the bar *materialises* on scroll rather than fading in.
|
||||
const GLASS = 'blur(20px) saturate(180%)';
|
||||
const MATERIALISE = 'background .25s ease, backdrop-filter .25s ease, -webkit-backdrop-filter .25s ease, border-color .25s ease';
|
||||
const overlay = { position: 'fixed', top: 0, left: 0, right: 0 };
|
||||
const headerStyle = onLanding
|
||||
? (scrolled
|
||||
? { ...overlay, background: 'rgba(15,15,17,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', borderBottom: '1px solid rgba(255,255,255,0.06)', transition: 'background .25s ease, backdrop-filter .25s ease, border-color .25s ease' }
|
||||
: { ...overlay, background: 'transparent', backdropFilter: 'none', WebkitBackdropFilter: 'none', borderBottom: '1px solid transparent', transition: 'background .25s ease, backdrop-filter .25s ease, border-color .25s ease' })
|
||||
: { background: 'rgba(18,18,20,0.72)', backdropFilter: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
||||
? { ...overlay, background: 'rgba(15,15,17,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)', transition: MATERIALISE }
|
||||
: { ...overlay, background: 'transparent', backdropFilter: 'blur(0px) saturate(100%)', WebkitBackdropFilter: 'blur(0px) saturate(100%)', borderBottom: '1px solid transparent', transition: MATERIALISE })
|
||||
: { background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
||||
|
||||
return React.createElement('header', {
|
||||
className: onLanding ? 'header-minimal z-50' : 'header-minimal sticky top-0 z-50',
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/* ============================================================================
|
||||
apple-motion.css — response, materials, type and reduced motion.
|
||||
|
||||
Loaded last, so it can settle arguments with the older sheets on source
|
||||
order alone. Three things live here, and nothing else:
|
||||
|
||||
1. Press feedback and input latency (§ "Response").
|
||||
Feedback belongs on pointer-DOWN. Waiting for the click to acknowledge
|
||||
a tap is the single cheapest way to make an interface feel dead, and
|
||||
almost every control in this app was hover-only — which on a phone
|
||||
means no feedback at all until the action has already happened.
|
||||
|
||||
2. Material weight and type (§ "Materials", § "Typography").
|
||||
Translucent chrome should read as a real, thick surface with content
|
||||
moving under it; tracking is size-specific, never one value for every
|
||||
size.
|
||||
|
||||
3. The three accessibility signals (§ "Reduced motion").
|
||||
Reduced motion is not "no feedback" — it is a non-vestibular
|
||||
equivalent. Looping decoration stops; entrances cross-fade in place
|
||||
instead of travelling; anything that communicates state stays.
|
||||
============================================================================ */
|
||||
|
||||
:root {
|
||||
/* One place for the curves, so a timing value is never invented twice.
|
||||
These mirror the spring parameters in src/ui/motion.js: CSS cannot do a
|
||||
real spring, but for a press — which is short and cannot be grabbed
|
||||
mid-flight — a curve is honest enough. */
|
||||
--sb-press: 110ms cubic-bezier(0.2, 0, 0, 1);
|
||||
--sb-settle: 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
}
|
||||
|
||||
/* ── 1. Response ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* `touch-action: manipulation` drops the ~300ms wait a mobile browser spends
|
||||
deciding whether a tap was the first half of a double-tap-to-zoom. Nothing
|
||||
here is zoomable, so that delay buys nothing and costs the illusion of
|
||||
directness. The tap highlight goes with it — we draw our own. */
|
||||
button,
|
||||
[role="button"],
|
||||
a,
|
||||
label,
|
||||
summary,
|
||||
input[type="checkbox"],
|
||||
input[type="radio"],
|
||||
.cursor-pointer {
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* The press itself. Deliberately low specificity (0,1,1): every control that
|
||||
already defines its own :active — .button, .btn-partner-modern, the header
|
||||
icons — keeps it, because a class selector outranks this. */
|
||||
button:not(:disabled):active,
|
||||
[role="button"]:not([aria-disabled="true"]):active,
|
||||
.cursor-pointer:active {
|
||||
transform: scale(0.97);
|
||||
transition: transform var(--sb-press);
|
||||
}
|
||||
|
||||
/* Release should settle rather than snap back at the same speed it depressed. */
|
||||
button,
|
||||
[role="button"] {
|
||||
transition: transform var(--sb-settle);
|
||||
}
|
||||
|
||||
/* Disabled controls must not pretend to respond. */
|
||||
button:disabled,
|
||||
[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* A pointer that cannot hover should not inherit hover state — on touch, :hover
|
||||
sticks after the tap and leaves controls lit up with no way to clear them. */
|
||||
@media (hover: none) {
|
||||
.card-minimal:hover,
|
||||
.wallet-logo:hover,
|
||||
.btn-primary:hover,
|
||||
.btn-secondary:hover,
|
||||
.btn-verify:hover,
|
||||
.lightning-button:hover,
|
||||
.btn-partner-modern:hover,
|
||||
.transfer-item:hover,
|
||||
.file-drop-zone:hover,
|
||||
.project-card:hover .project-card__bg {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard users get the same clarity as pointer users. */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgba(249, 115, 22, 0.75);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── 2. Materials ────────────────────────────────────────────────────────── */
|
||||
|
||||
/* The header's own background is set inline by Header.jsx (it cross-fades on
|
||||
scroll) so its blur is fixed there. What a class rule can still add is the
|
||||
bright top edge — light catching the near face of the glass, which is what
|
||||
separates a translucent bar from a washed-out rectangle. */
|
||||
.header-minimal {
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Cards are a smaller surface than chrome, so they read thinner: less blur,
|
||||
a shallower shadow. Weight is how a surface says how big it is. */
|
||||
.card-minimal {
|
||||
backdrop-filter: blur(14px) saturate(140%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(140%);
|
||||
}
|
||||
|
||||
/* ── 3. Typography ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* Tracking is size-specific. Letterforms drift apart as they grow, so display
|
||||
text needs negative tracking to hold together, and small text needs a touch
|
||||
of positive tracking to stay legible. A single letter-spacing value is wrong
|
||||
at one end of the scale or the other — usually both. Leading moves the
|
||||
opposite way: tight on headings, open on body copy. */
|
||||
html {
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
|
||||
h1, .text-6xl, .text-5xl { letter-spacing: -0.024em; line-height: 1.05; }
|
||||
h2, .text-4xl { letter-spacing: -0.021em; line-height: 1.1; }
|
||||
h3, .text-3xl { letter-spacing: -0.018em; line-height: 1.15; }
|
||||
.text-2xl { letter-spacing: -0.014em; }
|
||||
.text-xl { letter-spacing: -0.011em; }
|
||||
.text-lg { letter-spacing: -0.006em; }
|
||||
.text-base, p { letter-spacing: 0; }
|
||||
.text-sm { letter-spacing: 0.004em; }
|
||||
.text-xs { letter-spacing: 0.012em; }
|
||||
|
||||
/* Text over a blurred surface loses contrast against whatever is moving
|
||||
behind it. Flat mid-grey is the first thing to disappear; a little more
|
||||
weight and a little more tracking is what keeps it readable — the same
|
||||
trade vibrancy makes on Apple's platforms. */
|
||||
.header-minimal .text-secondary,
|
||||
.chat-input-area .text-secondary,
|
||||
.card-minimal .text-muted {
|
||||
color: #c3c3c9;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── 4. Reduced motion ───────────────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Looping, decorative motion has no state to communicate — it is the
|
||||
first thing to go, and nothing is lost by holding it on a final frame. */
|
||||
.wallet-logos-track,
|
||||
.button,
|
||||
.button::before,
|
||||
.icon-loading,
|
||||
[style*="sbPulse"],
|
||||
[style*="sbFlow"],
|
||||
[style*="sbNode"],
|
||||
[style*="sbScan"],
|
||||
[style*="sbTrav"],
|
||||
[style*="sbRing"],
|
||||
[style*="sbLiveBg"],
|
||||
[style*="sbCallPulse"],
|
||||
[style*="rmPulse"],
|
||||
[style*="vmRing"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* Entrances still happen — they explain that something new arrived — but
|
||||
they cross-fade in place instead of sliding, which is the part that
|
||||
provokes a vestibular response. */
|
||||
@keyframes sbFadeInPlace { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.message-slide,
|
||||
[style*="sbUp"],
|
||||
[style*="sbSlideUp"],
|
||||
[style*="sbExpand"],
|
||||
[style*="ccUp"],
|
||||
[style*="ptUp"],
|
||||
[style*="wuUp"],
|
||||
[style*="rmExp"],
|
||||
[style*="sbRow"],
|
||||
[style*="sbIn1"],
|
||||
[style*="sbIn2"] {
|
||||
animation: sbFadeInPlace 160ms ease-out both !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* A pulsing REC dot is status, not decoration: keep it, but as opacity
|
||||
alone — the scale component is what makes it jump. */
|
||||
[style*="vmRec"] {
|
||||
animation: vmRecSoft 1.4s ease-in-out infinite !important;
|
||||
transform: none !important;
|
||||
}
|
||||
@keyframes vmRecSoft { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
/* Nothing lifts, scales or hops. Named rather than `*:hover`, because a
|
||||
transform is also how things are centred — a blanket rule would drop
|
||||
elements out of position rather than merely still them. */
|
||||
.card-minimal:hover,
|
||||
.wallet-logo:hover,
|
||||
.btn-primary:hover,
|
||||
.btn-secondary:hover,
|
||||
.btn-verify:hover,
|
||||
.lightning-button:hover,
|
||||
.btn-partner-modern:hover,
|
||||
.transfer-item:hover,
|
||||
.file-drop-zone:hover,
|
||||
.project-card:hover,
|
||||
.project-card:hover .project-card__bg,
|
||||
.header-minimal .cursor-pointer:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
button:not(:disabled):active,
|
||||
[role="button"]:active,
|
||||
.cursor-pointer:active {
|
||||
transform: none !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* The mobile drawer's scrim opacity is driven imperatively, frame by frame,
|
||||
from src/ui/motion.js. Under normal motion a CSS transition on top of
|
||||
that would fight the finger; with motion reduced the spring resolves
|
||||
instantly, so the transition is the whole animation — the drawer
|
||||
cross-fades in place instead of sliding in from the edge. */
|
||||
.sb-drawer-overlay { transition: opacity 160ms ease !important; }
|
||||
|
||||
/* Programmatic smooth scrolling is a full-viewport slide. The JS callers
|
||||
check the same media query; this covers the CSS ones. */
|
||||
html, body, .scroll-smooth, .messages-container,
|
||||
.chat-messages-area, .sb-scroll, .track {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 5. Reduced transparency ─────────────────────────────────────────────── */
|
||||
|
||||
/* Frostier and heavier, not merely less blurred: the surface still has to
|
||||
separate itself from what is behind it, and without translucency the only
|
||||
tools left are opacity and a border. */
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
.header-minimal,
|
||||
.card-minimal,
|
||||
.sb-chat-header {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
.header-minimal { background: #18181a !important; }
|
||||
.card-minimal { background: #202022 !important; }
|
||||
}
|
||||
|
||||
/* ── 6. Increased contrast ───────────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
.card-minimal,
|
||||
.icon-container,
|
||||
.verification-code,
|
||||
.security-shield {
|
||||
background: #141416 !important;
|
||||
border-color: rgba(255, 255, 255, 0.42) !important;
|
||||
}
|
||||
.text-secondary { color: #e4e4e8 !important; }
|
||||
.text-muted { color: #c8c8cf !important; }
|
||||
:focus-visible { outline-width: 3px; }
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Motion primitives — Apple's "Designing Fluid Interfaces" physics, on the web.
|
||||
//
|
||||
// The whole point of this file is that a gesture-driven interface cannot be built
|
||||
// out of CSS transitions. A transition runs from a value it captured when it
|
||||
// started, for a duration fixed in advance; grab the element halfway through and
|
||||
// it either ignores you or snaps. A spring has no duration — it always integrates
|
||||
// from wherever the thing currently *is*, at whatever speed it is currently
|
||||
// moving — so it can be interrupted, re-targeted and reversed mid-flight without
|
||||
// a visible seam. Everything here exists to serve that one property.
|
||||
//
|
||||
// Nothing in here is a dependency. The app ships offline as a PWA and has no
|
||||
// bundled animation library; these are ~150 lines of physics instead of ~30KB.
|
||||
|
||||
// Apple exposes two designer-facing parameters instead of mass/stiffness/damping:
|
||||
//
|
||||
// damping (ratio) 1.0 = critically damped, settles without overshoot.
|
||||
// < 1.0 overshoots; lower is bouncier.
|
||||
// response (s) how quickly the value reaches the target. NOT a duration —
|
||||
// a spring's settle time emerges from the parameters.
|
||||
//
|
||||
// These are the values Apple ships, and the ones to reach for by default.
|
||||
export const SPRING = {
|
||||
// Move / reposition something. No overshoot: a panel that bounces when it
|
||||
// was not thrown reads as decoration.
|
||||
move: { damping: 1.0, response: 0.4 },
|
||||
// A sheet or drawer the user dragged. The bounce is earned — the gesture
|
||||
// carried momentum into it.
|
||||
drawer: { damping: 0.8, response: 0.3 },
|
||||
// Rotation.
|
||||
rotate: { damping: 0.8, response: 0.4 }
|
||||
};
|
||||
|
||||
// Integration step. Fixed and small, so the same gesture produces the same
|
||||
// motion on a 60Hz phone and a 120Hz iPad — frame-rate must not be a physics
|
||||
// parameter.
|
||||
const DT = 1 / 480;
|
||||
const MAX_FRAME = 0.064; // clamp after a stall, or the spring explodes
|
||||
|
||||
/**
|
||||
* True when the user has asked the system for less motion.
|
||||
*
|
||||
* Read live rather than cached: the setting can be flipped while the app is
|
||||
* open, and a long-lived PWA session would otherwise keep animating.
|
||||
*/
|
||||
export function prefersReducedMotion() {
|
||||
try {
|
||||
return typeof matchMedia === 'function' &&
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a flick would come to rest, if you let it decelerate.
|
||||
*
|
||||
* This is the exponential-decay projection from Apple's sample code, not the
|
||||
* textbook v²/2a. Use it to pick which snap point a gesture was aiming at:
|
||||
* snapping to whatever is nearest the *release* point throws away the user's
|
||||
* velocity and makes a fast flick feel identical to a slow drag.
|
||||
*
|
||||
* @param {number} velocity px/s at release
|
||||
* @param {number} decelerationRate 0.998 ≈ normal scroll feel, 0.99 snappier
|
||||
* @returns {number} signed distance the gesture would still travel
|
||||
*/
|
||||
export function project(velocity, decelerationRate = 0.998) {
|
||||
return (velocity / 1000) * decelerationRate / (1 - decelerationRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which snap point a release was aiming at.
|
||||
*
|
||||
* Two rules, in this order:
|
||||
*
|
||||
* A deliberate flick is decided by its *direction*. Someone who threw a panel
|
||||
* leftwards meant "away", even if they let go while it was still nearer the
|
||||
* open position than the closed one — position at the moment of release says
|
||||
* nothing about intent when the hand is still moving fast.
|
||||
*
|
||||
* A slow drag has no direction worth trusting, so it is decided by where its
|
||||
* remaining momentum would have carried it. That is still not the same as
|
||||
* "nearest the release point": a gentle push that was clearly going somewhere
|
||||
* should be allowed to arrive.
|
||||
*
|
||||
* @param {number} position current value at release
|
||||
* @param {number} velocity px/s at release
|
||||
* @param {number[]} points the values it is allowed to rest at
|
||||
* @param {object} [o]
|
||||
* @param {number} [o.flick] px/s above which direction wins over projection
|
||||
* @param {number} [o.decelerationRate] passed through to project()
|
||||
*/
|
||||
export function snapTarget(position, velocity, points, o = {}) {
|
||||
const { flick = 220, decelerationRate = 0.998 } = o;
|
||||
if (!points || !points.length) return position;
|
||||
const projected = position + project(velocity, decelerationRate);
|
||||
let pool = points;
|
||||
if (Math.abs(velocity) > flick) {
|
||||
const ahead = points.filter((p) => (velocity > 0 ? p >= position : p <= position));
|
||||
if (ahead.length) pool = ahead;
|
||||
}
|
||||
return pool.reduce(
|
||||
(best, p) => (Math.abs(p - projected) < Math.abs(best - projected) ? p : best),
|
||||
pool[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive resistance past a boundary.
|
||||
*
|
||||
* A hard stop reads as "the app froze". Continuous resistance reads as
|
||||
* "responsive, but there is nothing more this way" — the finger keeps moving
|
||||
* and the element keeps answering, just less and less.
|
||||
*
|
||||
* @param {number} overshoot how far past the bound the pointer is (px, signed)
|
||||
* @param {number} dimension the size the resistance is scaled against (px)
|
||||
* @param {number} constant lower = stiffer
|
||||
*/
|
||||
export function rubberband(overshoot, dimension, constant = 0.55) {
|
||||
if (!dimension) return 0;
|
||||
return (overshoot * dimension * constant) / (dimension + constant * Math.abs(overshoot));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a short position history so a release can be handed a real velocity.
|
||||
*
|
||||
* The instantaneous delta between the last two pointer events is far too noisy
|
||||
* to animate from — one stalled frame and a fast flick reports as a dead stop.
|
||||
* Averaging over a small trailing window is what makes the handoff invisible.
|
||||
*/
|
||||
export function velocityTracker(windowMs = 100) {
|
||||
const samples = [];
|
||||
return {
|
||||
add(value, time = performance.now()) {
|
||||
samples.push({ value, time });
|
||||
while (samples.length > 2 && time - samples[0].time > windowMs) samples.shift();
|
||||
},
|
||||
/** @returns {number} px/s over the trailing window */
|
||||
get(now = performance.now()) {
|
||||
if (samples.length < 2) return 0;
|
||||
const last = samples[samples.length - 1];
|
||||
// A pointer that has been still for a while has stopped, whatever
|
||||
// the older samples say.
|
||||
if (now - last.time > windowMs) return 0;
|
||||
const first = samples[0];
|
||||
const dt = (last.time - first.time) / 1000;
|
||||
if (dt <= 0) return 0;
|
||||
return (last.value - first.value) / dt;
|
||||
},
|
||||
reset() { samples.length = 0; }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A spring animation that can be interrupted, re-targeted and reversed.
|
||||
*
|
||||
* The handle deliberately exposes live `value` and `velocity`: that is what a
|
||||
* gesture needs when it grabs a moving element — it must continue from the
|
||||
* *presentation* value, never from the logical target, or the element jumps.
|
||||
*
|
||||
* @param {object} o
|
||||
* @param {number} o.from starting value
|
||||
* @param {number} o.to target value
|
||||
* @param {number} [o.velocity] initial velocity, px/s — hand the release velocity here
|
||||
* @param {number} [o.damping] 1 = no overshoot, < 1 bounces
|
||||
* @param {number} [o.response] seconds to reach the target
|
||||
* @param {number} [o.restDelta] how close counts as arrived
|
||||
* @param {number} [o.restSpeed] how slow counts as stopped, px/s
|
||||
* @param {boolean} [o.respectReducedMotion] jump to the target instead of animating
|
||||
* @param {(v:number)=>void} [o.onUpdate]
|
||||
* @param {()=>void} [o.onComplete]
|
||||
*/
|
||||
export function spring(o) {
|
||||
const {
|
||||
from, to, velocity = 0,
|
||||
damping = SPRING.move.damping,
|
||||
response = SPRING.move.response,
|
||||
restDelta = 0.1, restSpeed = 0.5,
|
||||
respectReducedMotion = true,
|
||||
onUpdate, onComplete
|
||||
} = o;
|
||||
|
||||
let value = from;
|
||||
let vel = velocity;
|
||||
let target = to;
|
||||
let raf = 0;
|
||||
let last = 0;
|
||||
let done = false;
|
||||
|
||||
const omega = (2 * Math.PI) / response;
|
||||
const zeta = damping;
|
||||
|
||||
const finish = () => {
|
||||
done = true;
|
||||
raf = 0;
|
||||
value = target;
|
||||
vel = 0;
|
||||
if (onUpdate) onUpdate(value);
|
||||
if (onComplete) onComplete();
|
||||
};
|
||||
|
||||
// Reduced motion means no vestibular travel — but it does not mean no
|
||||
// feedback. The caller still gets its final value; it is up to the caller
|
||||
// to cross-fade instead of slide.
|
||||
if (respectReducedMotion && prefersReducedMotion()) {
|
||||
finish();
|
||||
return {
|
||||
get value() { return value; },
|
||||
get velocity() { return 0; },
|
||||
get done() { return true; },
|
||||
retarget(next) { target = next; finish(); },
|
||||
stop() {}
|
||||
};
|
||||
}
|
||||
|
||||
const step = (now) => {
|
||||
raf = 0;
|
||||
const frame = Math.min((now - last) / 1000, MAX_FRAME);
|
||||
last = now;
|
||||
|
||||
// Semi-implicit Euler at a fixed sub-step. Explicit Euler at frame size
|
||||
// is unstable for a stiff spring; this stays well inside the stability
|
||||
// bound for every response value we use.
|
||||
let t = frame;
|
||||
while (t > 0) {
|
||||
const dt = Math.min(DT, t);
|
||||
const accel = -omega * omega * (value - target) - 2 * zeta * omega * vel;
|
||||
vel += accel * dt;
|
||||
value += vel * dt;
|
||||
t -= dt;
|
||||
}
|
||||
|
||||
if (Math.abs(value - target) < restDelta && Math.abs(vel) < restSpeed) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (onUpdate) onUpdate(value);
|
||||
raf = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
last = performance.now();
|
||||
raf = requestAnimationFrame(step);
|
||||
|
||||
return {
|
||||
get value() { return value; },
|
||||
get velocity() { return vel; },
|
||||
get done() { return done; },
|
||||
/**
|
||||
* Point the spring somewhere else without touching its current velocity.
|
||||
*
|
||||
* This is the anti-"brick wall" move. Killing this animation and starting
|
||||
* a new one at velocity 0 puts a discontinuity exactly where the user
|
||||
* reversed direction, which is the moment they are most attentive.
|
||||
*/
|
||||
retarget(next) { target = next; },
|
||||
stop() {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = 0;
|
||||
done = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user