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:
@@ -18,3 +18,10 @@ config/ice-servers.js
|
|||||||
SECURITY_AUDIT.md
|
SECURITY_AUDIT.md
|
||||||
SECURITY_AUDIT*.md
|
SECURITY_AUDIT*.md
|
||||||
audit/
|
audit/
|
||||||
|
|
||||||
|
# Agent tooling — local only. Skills (mine or third-party), their lockfile and
|
||||||
|
# the per-machine Claude Code settings describe how someone works on this repo,
|
||||||
|
# not what the repo is. They must never reach GitHub.
|
||||||
|
.agents/
|
||||||
|
.claude/
|
||||||
|
skills-lock.json
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v6.2.0 — The interface answers you now
|
||||||
|
|
||||||
|
Most of this release you will feel rather than see.
|
||||||
|
|
||||||
|
**The menu on your phone is something you can drag.** Before, it was either
|
||||||
|
there or it wasn't — it appeared, and tapping outside made it vanish. Now you
|
||||||
|
can pull it in and push it away with your finger, and it stays under your thumb
|
||||||
|
the whole way. Flick it and it goes; catch it while it's still moving and it
|
||||||
|
follows you back. Push it past the edge and it resists instead of stopping dead,
|
||||||
|
so you can feel that there's nothing more that way.
|
||||||
|
|
||||||
|
**Buttons react the moment you press them, not when you let go.** On a phone
|
||||||
|
that was the difference between an app that felt asleep and one that answers.
|
||||||
|
Along with it went the small delay every mobile browser adds to a tap while it
|
||||||
|
waits to see whether you meant to zoom — nothing here zooms, so the wait bought
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
**If you've asked your phone or computer for less motion, the app now listens.**
|
||||||
|
Looping decoration stops, things fade into place instead of sliding, and the chat
|
||||||
|
jumps to the newest message instead of scrolling to it. Recording indicators and
|
||||||
|
anything else that tells you what's happening stay — the point is to stop the
|
||||||
|
movement, not the information. The same goes for the system settings for reduced
|
||||||
|
transparency and higher contrast, which the app previously ignored: frosted
|
||||||
|
panels become solid, and edges get drawn properly.
|
||||||
|
|
||||||
|
### Also in this release
|
||||||
|
|
||||||
|
- Headings are set more tightly and small print more openly, the way type is
|
||||||
|
meant to change shape as it changes size. Text over blurred panels is a little
|
||||||
|
heavier so it stays readable against whatever is moving behind it.
|
||||||
|
- The bar at the top of the page is a thicker piece of glass, and it now
|
||||||
|
materialises as you scroll rather than simply fading in.
|
||||||
|
- Controls no longer stay stuck in a highlighted state after you tap them on a
|
||||||
|
touchscreen.
|
||||||
|
- Keyboard focus is visible everywhere.
|
||||||
|
- The desktop download buttons now offer 0.5.0, the release that adds group
|
||||||
|
chats. Linux gained `.deb` and `.rpm` packages alongside the AppImage the
|
||||||
|
button hands you.
|
||||||
|
|
||||||
## v6.1.1 — Group chats now connect everyone to everyone
|
## v6.1.1 — Group chats now connect everyone to everyone
|
||||||
|
|
||||||
Group chats used to run through whoever created them. That person held a
|
Group chats used to run through whoever created them. That person held a
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
No accounts. No servers storing your messages. No installation required.
|
No accounts. No servers storing your messages. No installation required.
|
||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](#install-as-an-app)
|
[](#install-as-an-app)
|
||||||
[](#security-model)
|
[](#security-model)
|
||||||
[](#forward-secrecy)
|
[](#forward-secrecy)
|
||||||
|
|||||||
Vendored
+5
-3
@@ -21332,7 +21332,7 @@ var SecureMasterKeyManager = class {
|
|||||||
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
|
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
|
||||||
|
|
||||||
// package.json
|
// package.json
|
||||||
var version = "6.1.1";
|
var version = "6.2.0";
|
||||||
|
|
||||||
// src/components/ui/Header.jsx
|
// src/components/ui/Header.jsx
|
||||||
var APP_VERSION = `v${version}`;
|
var APP_VERSION = `v${version}`;
|
||||||
@@ -21750,8 +21750,10 @@ Right-click or Ctrl+click to disconnect`,
|
|||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
return () => window.removeEventListener("scroll", onScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
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 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)" };
|
const headerStyle = onLanding ? scrolled ? { ...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", {
|
return React.createElement("header", {
|
||||||
className: onLanding ? "header-minimal z-50" : "header-minimal sticky top-0 z-50",
|
className: onLanding ? "header-minimal z-50" : "header-minimal sticky top-0 z-50",
|
||||||
style: headerStyle
|
style: headerStyle
|
||||||
@@ -21828,7 +21830,7 @@ Right-click or Ctrl+click to disconnect`,
|
|||||||
window.EnhancedMinimalHeader = EnhancedMinimalHeader;
|
window.EnhancedMinimalHeader = EnhancedMinimalHeader;
|
||||||
|
|
||||||
// src/components/ui/DownloadApps.jsx
|
// src/components/ui/DownloadApps.jsx
|
||||||
var DESKTOP_VERSION = "0.3.0";
|
var DESKTOP_VERSION = "0.5.0";
|
||||||
var DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${DESKTOP_VERSION}`;
|
var DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${DESKTOP_VERSION}`;
|
||||||
var DownloadApps = () => {
|
var DownloadApps = () => {
|
||||||
const apps = [
|
const apps = [
|
||||||
|
|||||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+306
-9
@@ -2698,6 +2698,167 @@ function createGroupSender({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// src/ui/motion.js
|
||||||
|
var SPRING = {
|
||||||
|
// Move / reposition something. No overshoot: a panel that bounces when it
|
||||||
|
// was not thrown reads as decoration.
|
||||||
|
move: { damping: 1, 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 }
|
||||||
|
};
|
||||||
|
var DT = 1 / 480;
|
||||||
|
var MAX_FRAME = 0.064;
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
try {
|
||||||
|
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function project(velocity, decelerationRate = 0.998) {
|
||||||
|
return velocity / 1e3 * decelerationRate / (1 - decelerationRate);
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function rubberband(overshoot, dimension, constant = 0.55) {
|
||||||
|
if (!dimension) return 0;
|
||||||
|
return overshoot * dimension * constant / (dimension + constant * Math.abs(overshoot));
|
||||||
|
}
|
||||||
|
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];
|
||||||
|
if (now - last.time > windowMs) return 0;
|
||||||
|
const first = samples[0];
|
||||||
|
const dt = (last.time - first.time) / 1e3;
|
||||||
|
if (dt <= 0) return 0;
|
||||||
|
return (last.value - first.value) / dt;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
samples.length = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
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) / 1e3, MAX_FRAME);
|
||||||
|
last = now;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// src/components/ui/GroupChat.jsx
|
// src/components/ui/GroupChat.jsx
|
||||||
var h = (...args) => React.createElement(...args);
|
var h = (...args) => React.createElement(...args);
|
||||||
var C = {
|
var C = {
|
||||||
@@ -3476,6 +3637,7 @@ function GroupChatView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/app.jsx
|
// src/app.jsx
|
||||||
|
var scrollBehavior = () => prefersReducedMotion() ? "auto" : "smooth";
|
||||||
var copyToClipboardSecure = async (text, autoClearMs = 0) => {
|
var copyToClipboardSecure = async (text, autoClearMs = 0) => {
|
||||||
let ok = false;
|
let ok = false;
|
||||||
try {
|
try {
|
||||||
@@ -4879,7 +5041,7 @@ var EnhancedConnectionSetup = ({
|
|||||||
}, disabled: !hasInvite || connectionStatus === "connecting", style: { width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "14px", borderRadius: "13px", border: "none", background: hasInvite && connectionStatus !== "connecting" ? C_ORANGE : "rgba(255,255,255,0.05)", color: hasInvite && connectionStatus !== "connecting" ? "#1a0f04" : "#56565e", fontFamily: "inherit", fontSize: "15px", fontWeight: 700, cursor: hasInvite && connectionStatus !== "connecting" ? "pointer" : "not-allowed", boxShadow: hasInvite && connectionStatus !== "connecting" ? "0 8px 24px rgba(240,137,42,0.28)" : "none" } }, connectionStatus === "connecting" ? "Processing\u2026" : "Connect")
|
}, disabled: !hasInvite || connectionStatus === "connecting", style: { width: "100%", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "9px", padding: "14px", borderRadius: "13px", border: "none", background: hasInvite && connectionStatus !== "connecting" ? C_ORANGE : "rgba(255,255,255,0.05)", color: hasInvite && connectionStatus !== "connecting" ? "#1a0f04" : "#56565e", fontFamily: "inherit", fontSize: "15px", fontWeight: 700, cursor: hasInvite && connectionStatus !== "connecting" ? "pointer" : "not-allowed", boxShadow: hasInvite && connectionStatus !== "connecting" ? "0 8px 24px rgba(240,137,42,0.28)" : "none" } }, connectionStatus === "connecting" ? "Processing\u2026" : "Connect")
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
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 SB_DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${SB_DESKTOP_VERSION}`;
|
||||||
const DOWNLOADS = {
|
const DOWNLOADS = {
|
||||||
mac: { name: "macOS", format: ".dmg \xB7 Apple Silicon & Intel", icon: "fab fa-apple", url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_x64.dmg` },
|
mac: { name: "macOS", format: ".dmg \xB7 Apple Silicon & Intel", icon: "fab fa-apple", url: `${SB_DESKTOP_RELEASE}/SecureBit.Chat_${SB_DESKTOP_VERSION}_x64.dmg` },
|
||||||
@@ -5028,7 +5190,7 @@ var createScrollToBottomFunction = (chatMessagesRef) => {
|
|||||||
if (chatMessagesRef.current) {
|
if (chatMessagesRef.current) {
|
||||||
chatMessagesRef.current.scrollTo({
|
chatMessagesRef.current.scrollTo({
|
||||||
top: chatMessagesRef.current.scrollHeight,
|
top: chatMessagesRef.current.scrollHeight,
|
||||||
behavior: "smooth"
|
behavior: scrollBehavior()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -5417,7 +5579,7 @@ var EnhancedChatInterface = ({
|
|||||||
if (chatMessagesRef.current) {
|
if (chatMessagesRef.current) {
|
||||||
chatMessagesRef.current.scrollTo({
|
chatMessagesRef.current.scrollTo({
|
||||||
top: chatMessagesRef.current.scrollHeight,
|
top: chatMessagesRef.current.scrollHeight,
|
||||||
behavior: "smooth"
|
behavior: scrollBehavior()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -5439,7 +5601,7 @@ var EnhancedChatInterface = ({
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
} else if (chatMessagesRef.current) {
|
} else if (chatMessagesRef.current) {
|
||||||
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: "smooth" });
|
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: scrollBehavior() });
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -5790,6 +5952,134 @@ var SessionsSidebar = ({ chats, groups = [], collapsed, drawerOpen, onToggleColl
|
|||||||
const [editingId, setEditingId] = React.useState(null);
|
const [editingId, setEditingId] = React.useState(null);
|
||||||
const [draft, setDraft] = React.useState("");
|
const [draft, setDraft] = React.useState("");
|
||||||
const [presenceOpen, setPresenceOpen] = React.useState(false);
|
const [presenceOpen, setPresenceOpen] = React.useState(false);
|
||||||
|
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);
|
||||||
|
const openRef = React.useRef(false);
|
||||||
|
const clickGuardRef = React.useRef(false);
|
||||||
|
const [drawerMounted, setDrawerMounted] = React.useState(!!drawerOpen);
|
||||||
|
const AXIS_LOCK = 10;
|
||||||
|
const FLICK = 220;
|
||||||
|
const drawerWidth = () => panelRef.current && panelRef.current.offsetWidth || 292;
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
const w = drawerWidth();
|
||||||
|
if (drawerOpen && !animRef.current && !dragRef.current) {
|
||||||
|
paintDrawer(-w);
|
||||||
|
}
|
||||||
|
openRef.current = drawerOpen;
|
||||||
|
settleDrawer(drawerOpen ? 0 : -w, animRef.current ? animRef.current.velocity : 0);
|
||||||
|
}, [drawerOpen, drawerMounted]);
|
||||||
|
React.useEffect(() => () => {
|
||||||
|
if (animRef.current) animRef.current.stop();
|
||||||
|
}, []);
|
||||||
|
const drawerDown = (e) => {
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
if (dragRef.current) return;
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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);
|
||||||
|
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 !== void 0 && e.pointerId !== d.id) return;
|
||||||
|
dragRef.current = null;
|
||||||
|
if (d.axis !== "x") return;
|
||||||
|
clickGuardRef.current = true;
|
||||||
|
const w = drawerWidth();
|
||||||
|
const v = d.vel.get(e.timeStamp || performance.now());
|
||||||
|
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) => {
|
const startEdit = (c) => (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingId(c.id);
|
setEditingId(c.id);
|
||||||
@@ -5998,13 +6288,20 @@ var SessionsSidebar = ({ chats, groups = [], collapsed, drawerOpen, onToggleColl
|
|||||||
h2("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;min-height:0 !important;overflow:hidden;}.sb-chat-header{position:sticky;top:0;z-index:20;}.sb-app-col{height:100% !important;min-height:0 !important;}.chat-container{height:100% !important;min-height:0 !important;}.sb-scroll{min-height:0 !important;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
|
h2("style", { key: "css", dangerouslySetInnerHTML: { __html: "@media (max-width:1023px){.sb-rail{display:none !important;}.sb-burger{display:grid !important;}}@media (min-width:1024px){.sb-drawer-overlay{display:none !important;}}.sb-mobile-drawer .sb-collapse-btn{display:none !important;}html,body{background:#0f0f11 !important;overscroll-behavior:none;}.sb-app-shell{height:var(--sb-vh,100dvh) !important;min-height:0 !important;overflow:hidden;}.sb-chat-header{position:sticky;top:0;z-index:20;}.sb-app-col{height:100% !important;min-height:0 !important;}.chat-container{height:100% !important;min-height:0 !important;}.sb-scroll{min-height:0 !important;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;}@media (max-width:768px){textarea,input,select{font-size:16px !important;}}@media (max-width:768px){.sb-rename-btn{display:none !important;}}" } }),
|
||||||
// Desktop rail
|
// Desktop rail
|
||||||
h2("aside", { key: "rail", className: "sb-rail", style: railStyle }, inner),
|
h2("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.
|
||||||
h2("div", {
|
h2("div", {
|
||||||
key: "drawer",
|
key: "drawer",
|
||||||
className: "sb-drawer-overlay",
|
className: "sb-drawer-overlay",
|
||||||
onClick: onCloseDrawer,
|
ref: scrimRef,
|
||||||
style: { position: "fixed", inset: 0, zIndex: 60, background: "rgba(6,6,8,0.6)", backdropFilter: "blur(4px)", WebkitBackdropFilter: "blur(4px)", display: drawerOpen ? "block" : "none" }
|
onClick: drawerScrimClick,
|
||||||
}, h2("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)" } }, [
|
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" }
|
||||||
|
}, h2("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
|
// Explicit close button — the drawer's own header only has a
|
||||||
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
||||||
// no obvious way to dismiss it. This X closes the drawer reliably.
|
// no obvious way to dismiss it. This X closes the drawer reliably.
|
||||||
@@ -6447,7 +6744,7 @@ var EnhancedSecureP2PChat = () => {
|
|||||||
if (container && container.scrollTo) {
|
if (container && container.scrollTo) {
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
top: container.scrollHeight,
|
top: container.scrollHeight,
|
||||||
behavior: "smooth"
|
behavior: scrollBehavior()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,7 +7,7 @@ this document describes.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Release | v6.1.1 |
|
| Release | v6.2.0 |
|
||||||
| Protocol version | 4.1 |
|
| Protocol version | 4.1 |
|
||||||
| Ratchet wire version | 1 |
|
| Ratchet wire version | 1 |
|
||||||
|
|
||||||
@@ -308,5 +308,5 @@ and receiving their group traffic.
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This describes the browser implementation as it stands in v6.1.1. It is not a
|
This describes the browser implementation as it stands in v6.2.0. It is not a
|
||||||
substitute for independent cryptographic review.
|
substitute for independent cryptographic review.
|
||||||
|
|||||||
+26
-22
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="./manifest.json">
|
<link rel="manifest" href="./manifest.json">
|
||||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1787689263870">
|
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1787944850227">
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||||
|
|
||||||
<!-- Apple Touch Icons -->
|
<!-- Apple Touch Icons -->
|
||||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1787689263870">
|
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1787944850227">
|
||||||
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
|
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
|
||||||
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
|
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
|
||||||
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
|
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
|
||||||
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
|
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
|
||||||
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
|
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1787689263870">
|
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1787944850227">
|
||||||
|
|
||||||
<!-- Microsoft Tiles -->
|
<!-- Microsoft Tiles -->
|
||||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||||
both execute in document order after parsing, so React still runs before the
|
both execute in document order after parsing, so React still runs before the
|
||||||
app modules below, but the parser / first paint is no longer blocked. -->
|
app modules below, but the parser / first paint is no longer blocked. -->
|
||||||
<script defer src="config/ice-servers.js?v=1787689263870"></script>
|
<script defer src="config/ice-servers.js?v=1787944850227"></script>
|
||||||
<script defer src="libs/react/react.production.min.js"></script>
|
<script defer src="libs/react/react.production.min.js"></script>
|
||||||
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
|
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
|
||||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||||
@@ -195,8 +195,8 @@
|
|||||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||||
<script defer src="libs/prism/prism.js"></script>
|
<script defer src="libs/prism/prism.js"></script>
|
||||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||||
<link rel="stylesheet" href="assets/tailwind.css?v=1787689263870">
|
<link rel="stylesheet" href="assets/tailwind.css?v=1787944850227">
|
||||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1787689263870">
|
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1787944850227">
|
||||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||||
@@ -204,31 +204,35 @@
|
|||||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||||
<link rel="stylesheet" href="src/styles/main.css?v=1787689263870">
|
<link rel="stylesheet" href="src/styles/main.css?v=1787944850227">
|
||||||
<link rel="stylesheet" href="src/styles/animations.css?v=1787689263870">
|
<link rel="stylesheet" href="src/styles/animations.css?v=1787944850227">
|
||||||
<link rel="stylesheet" href="src/styles/components.css?v=1787689263870">
|
<link rel="stylesheet" href="src/styles/components.css?v=1787944850227">
|
||||||
|
<!-- Loads after components.css on purpose: press feedback, material weight,
|
||||||
|
size-specific tracking and the reduced-motion / -transparency / -contrast
|
||||||
|
answers all need to settle arguments with the sheets above on source order. -->
|
||||||
|
<link rel="stylesheet" href="src/styles/apple-motion.css?v=1787944850227">
|
||||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||||
<script defer src="src/scripts/load-async-css.js?v=1787689263870"></script>
|
<script defer src="src/scripts/load-async-css.js?v=1787944850227"></script>
|
||||||
<noscript>
|
<noscript>
|
||||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||||
<link rel="stylesheet" href="libs/prism/prism.css">
|
<link rel="stylesheet" href="libs/prism/prism.css">
|
||||||
</noscript>
|
</noscript>
|
||||||
<script defer src="src/scripts/fa-check.js?v=1787689263870"></script>
|
<script defer src="src/scripts/fa-check.js?v=1787944850227"></script>
|
||||||
<!-- Update Manager - система принудительного обновления -->
|
<!-- Update Manager - система принудительного обновления -->
|
||||||
<script defer src="src/utils/updateManager.js?v=1787689263870"></script>
|
<script defer src="src/utils/updateManager.js?v=1787944850227"></script>
|
||||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1787689263870"></script>
|
<script type="module" src="src/components/UpdateChecker.jsx?v=1787944850227"></script>
|
||||||
<script type="module" src="dist/qr-local.js?v=1787689263870"></script>
|
<script type="module" src="dist/qr-local.js?v=1787944850227"></script>
|
||||||
<script type="module" src="src/components/QRScanner.js?v=1787689263870"></script>
|
<script type="module" src="src/components/QRScanner.js?v=1787944850227"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="dist/app-boot.js?v=1787689263870"></script>
|
<script type="module" src="dist/app-boot.js?v=1787944850227"></script>
|
||||||
<script type="module" src="dist/app.js?v=1787689263870"></script>
|
<script type="module" src="dist/app.js?v=1787944850227"></script>
|
||||||
|
|
||||||
<script defer src="src/scripts/pwa-register.js?v=1787689263870"></script>
|
<script defer src="src/scripts/pwa-register.js?v=1787944850227"></script>
|
||||||
<script src="./src/pwa/install-prompt.js?v=1787689263870" type="module"></script>
|
<script src="./src/pwa/install-prompt.js?v=1787944850227" type="module"></script>
|
||||||
<script src="./src/pwa/pwa-manager.js?v=1787689263870" type="module"></script>
|
<script src="./src/pwa/pwa-manager.js?v=1787944850227" type="module"></script>
|
||||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1787689263870"></script>
|
<script defer src="./src/scripts/pwa-offline-test.js?v=1787944850227"></script>
|
||||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1787689263870">
|
<link rel="stylesheet" href="./src/styles/pwa.css?v=1787944850227">
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"version": "1787689263870",
|
"version": "1787944850227",
|
||||||
"buildVersion": "1787689263870",
|
"buildVersion": "1787944850227",
|
||||||
"appVersion": "6.1.1",
|
"appVersion": "6.2.0",
|
||||||
"buildTime": "2026-08-25T20:21:03.926Z",
|
"buildTime": "2026-08-28T19:20:50.271Z",
|
||||||
"buildId": "1787689263870-6a98e2e",
|
"buildId": "1787944850227-e00c3bd",
|
||||||
"gitHash": "6a98e2e",
|
"gitHash": "e00c3bd",
|
||||||
"generated": true,
|
"generated": true,
|
||||||
"generatedAt": "2026-08-25T20:21:03.928Z"
|
"generatedAt": "2026-08-28T19:20:50.272Z"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "securebit-chat",
|
"name": "securebit-chat",
|
||||||
"version": "6.1.1",
|
"version": "6.2.0",
|
||||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||||
"main": "index.html",
|
"main": "index.html",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"dev": "npm run build && python -m http.server 8000",
|
"dev": "npm run build && python -m http.server 8000",
|
||||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||||
"serve": "npx http-server -p 8000",
|
"serve": "npx http-server -p 8000",
|
||||||
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.test.mjs && node tests/group-crypto.test.mjs && node tests/groups-reducer.test.mjs && node tests/group-session-e2e.test.mjs && node tests/group-app-integration.test.mjs && node tests/group-sender.test.mjs && node tests/group-mesh.test.mjs"
|
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs && node tests/sbq2-key-exchange.test.mjs && node tests/qr-scan-single-frame.test.mjs && node tests/mobile-chat-layout.test.mjs && node tests/group-crypto.test.mjs && node tests/groups-reducer.test.mjs && node tests/group-session-e2e.test.mjs && node tests/group-app-integration.test.mjs && node tests/group-sender.test.mjs && node tests/group-mesh.test.mjs && node tests/apple-motion.test.mjs"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"p2p",
|
"p2p",
|
||||||
|
|||||||
+179
-9
@@ -24,6 +24,12 @@ import {
|
|||||||
import { GroupSession, GROUP_FRAMES, isGroupFrame, groupFrameType, decodeEnvelope } from './group/GroupSession.js';
|
import { GroupSession, GROUP_FRAMES, isGroupFrame, groupFrameType, decodeEnvelope } from './group/GroupSession.js';
|
||||||
import { GROUP_LIMITS } from './group/groupCrypto.js';
|
import { GROUP_LIMITS } from './group/groupCrypto.js';
|
||||||
import { createGroupSender } from './group/groupSender.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';
|
import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, GroupErrorModal, AddMembersModal } from './components/ui/GroupChat.jsx';
|
||||||
|
|
||||||
// ── Secure chat extras: code blocks, clipboard hygiene ──────────────
|
// ── 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
|
// /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
|
// existed, and the download 404s. A pinned tag keeps serving a real
|
||||||
// installer instead.
|
// 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 SB_DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${SB_DESKTOP_VERSION}`;
|
||||||
const DOWNLOADS = {
|
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` },
|
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) {
|
if (chatMessagesRef.current) {
|
||||||
chatMessagesRef.current.scrollTo({
|
chatMessagesRef.current.scrollTo({
|
||||||
top: chatMessagesRef.current.scrollHeight,
|
top: chatMessagesRef.current.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: scrollBehavior()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2221,7 +2227,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
|||||||
if (chatMessagesRef.current) {
|
if (chatMessagesRef.current) {
|
||||||
chatMessagesRef.current.scrollTo({
|
chatMessagesRef.current.scrollTo({
|
||||||
top: chatMessagesRef.current.scrollHeight,
|
top: chatMessagesRef.current.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: scrollBehavior()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2245,7 +2251,7 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
} else if (chatMessagesRef.current) {
|
} else if (chatMessagesRef.current) {
|
||||||
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: 'smooth' });
|
chatMessagesRef.current.scrollTo({ top: chatMessagesRef.current.scrollHeight, behavior: scrollBehavior() });
|
||||||
setShowScrollButton(false);
|
setShowScrollButton(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2572,6 +2578,163 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
|||||||
const [editingId, setEditingId] = React.useState(null);
|
const [editingId, setEditingId] = React.useState(null);
|
||||||
const [draft, setDraft] = React.useState('');
|
const [draft, setDraft] = React.useState('');
|
||||||
const [presenceOpen, setPresenceOpen] = React.useState(false);
|
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 startEdit = (c) => (e) => { e.stopPropagation(); setEditingId(c.id); setDraft(c.name); };
|
||||||
const commitEdit = () => { if (editingId) { onRename(editingId, draft); setEditingId(null); } };
|
const commitEdit = () => { if (editingId) { onRename(editingId, draft); setEditingId(null); } };
|
||||||
const editKey = (e) => {
|
const editKey = (e) => {
|
||||||
@@ -2836,12 +2999,19 @@ import { GroupChatView, GroupSasModal, CreateGroupModal, GroupInviteModal, Group
|
|||||||
'@media (max-width:768px){.sb-rename-btn{display:none !important;}}' } }),
|
'@media (max-width:768px){.sb-rename-btn{display:none !important;}}' } }),
|
||||||
// Desktop rail
|
// Desktop rail
|
||||||
h('aside', { key: 'rail', className: 'sb-rail', style: railStyle }, inner),
|
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', {
|
h('div', {
|
||||||
key: 'drawer', className: 'sb-drawer-overlay',
|
key: 'drawer', className: 'sb-drawer-overlay',
|
||||||
onClick: onCloseDrawer,
|
ref: scrimRef,
|
||||||
style: { position: 'fixed', inset: 0, zIndex: 60, background: 'rgba(6,6,8,0.6)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', display: drawerOpen ? 'block' : 'none' }
|
onClick: drawerScrimClick,
|
||||||
}, 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)' } }, [
|
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
|
// Explicit close button — the drawer's own header only has a
|
||||||
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
// "collapse" chevron (a desktop-rail action), so on mobile there was
|
||||||
// no obvious way to dismiss it. This X closes the drawer reliably.
|
// 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) {
|
if (container && container.scrollTo) {
|
||||||
container.scrollTo({
|
container.scrollTo({
|
||||||
top: container.scrollHeight,
|
top: container.scrollHeight,
|
||||||
behavior: 'smooth'
|
behavior: scrollBehavior()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// The tag is pinned rather than using /releases/latest/download/: the filenames
|
// 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,
|
// 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.
|
// 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 DESKTOP_RELEASE = `https://github.com/SecureBitChat/securebit-desktop/releases/download/v${DESKTOP_VERSION}`;
|
||||||
|
|
||||||
const DownloadApps = () => {
|
const DownloadApps = () => {
|
||||||
|
|||||||
@@ -538,12 +538,18 @@ const EnhancedMinimalHeader = ({
|
|||||||
// On the landing the header floats *over* the full-height hero (position
|
// On the landing the header floats *over* the full-height hero (position
|
||||||
// fixed), transparent at the top and blurred once scrolled. When connected it
|
// fixed), transparent at the top and blurred once scrolled. When connected it
|
||||||
// falls back to the in-flow sticky bar.
|
// 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 overlay = { position: 'fixed', top: 0, left: 0, right: 0 };
|
||||||
const headerStyle = onLanding
|
const headerStyle = onLanding
|
||||||
? (scrolled
|
? (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: '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: 'none', WebkitBackdropFilter: 'none', borderBottom: '1px solid transparent', transition: 'background .25s ease, backdrop-filter .25s ease, border-color .25s ease' })
|
: { ...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: 'blur(14px)', WebkitBackdropFilter: 'blur(14px)', borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
: { background: 'rgba(18,18,20,0.72)', backdropFilter: GLASS, WebkitBackdropFilter: GLASS, borderBottom: '1px solid rgba(255,255,255,0.06)' };
|
||||||
|
|
||||||
return React.createElement('header', {
|
return React.createElement('header', {
|
||||||
className: onLanding ? 'header-minimal z-50' : 'header-minimal sticky top-0 z-50',
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
|
|||||||
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
|
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
|
||||||
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
|
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
|
||||||
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
||||||
const SW_BUILD_VERSION = '1787689263870';
|
const SW_BUILD_VERSION = '1787944850227';
|
||||||
|
|
||||||
// Load version from meta.json on install
|
// Load version from meta.json on install
|
||||||
async function getAppVersion() {
|
async function getAppVersion() {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
// The motion primitives, and the places the app is wired to them.
|
||||||
|
//
|
||||||
|
// These are unit tests for physics, which is unusual for this repo, but the
|
||||||
|
// numbers here are the ones a gesture is judged by. A projection that is off by
|
||||||
|
// a factor of two turns a flick into a shove; a spring that overshoots when it
|
||||||
|
// was told not to puts a wobble on a panel nobody threw. Both are invisible in
|
||||||
|
// a screenshot and obvious in the hand, so they get asserted instead of eyeballed.
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A controllable clock, installed before the module under test is imported so
|
||||||
|
// its rAF loop runs on virtual time. Real timers would make this test slow and
|
||||||
|
// flaky for no benefit — the integrator only cares about the deltas it is fed.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
let now = 0;
|
||||||
|
let nextId = 1;
|
||||||
|
const frames = new Map();
|
||||||
|
|
||||||
|
globalThis.performance = { now: () => now };
|
||||||
|
globalThis.requestAnimationFrame = (cb) => { const id = nextId++; frames.set(id, cb); return id; };
|
||||||
|
globalThis.cancelAnimationFrame = (id) => { frames.delete(id); };
|
||||||
|
|
||||||
|
let reduceMotion = false;
|
||||||
|
globalThis.matchMedia = (q) => ({
|
||||||
|
matches: q.includes('prefers-reduced-motion') ? reduceMotion : false
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Advance the virtual clock one frame and run whatever was scheduled. */
|
||||||
|
function tick(ms = 16) {
|
||||||
|
now += ms;
|
||||||
|
const due = [...frames.entries()];
|
||||||
|
frames.clear();
|
||||||
|
for (const [, cb] of due) cb(now);
|
||||||
|
}
|
||||||
|
/** Run frames until the spring reports done, or give up. */
|
||||||
|
function run(handle, maxFrames = 600) {
|
||||||
|
let n = 0;
|
||||||
|
while (!handle.done && n++ < maxFrames) tick();
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { spring, project, snapTarget, rubberband, velocityTracker, prefersReducedMotion, SPRING } =
|
||||||
|
await import('../src/ui/motion.js');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// project() — momentum, not proximity
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
// Apple's exponential-decay form, not the textbook v²/2a. At the default
|
||||||
|
// deceleration rate a metre-per-second flick carries about half a screen.
|
||||||
|
assert.ok(Math.abs(project(1000) - 499) < 1,
|
||||||
|
'project(1000px/s) must be ~499px — this is the constant every snap ' +
|
||||||
|
'decision is measured against');
|
||||||
|
|
||||||
|
assert.equal(project(0), 0, 'a release with no velocity travels nowhere');
|
||||||
|
assert.ok(project(-800) < 0, 'projection keeps the sign of the gesture');
|
||||||
|
assert.ok(project(2000) > project(1000),
|
||||||
|
'a faster flick must project further, or velocity is being discarded');
|
||||||
|
|
||||||
|
// A snappier deceleration rate must land shorter, not longer.
|
||||||
|
assert.ok(project(1000, 0.99) < project(1000, 0.998));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// snapTarget() — where the gesture was going, not where it stopped
|
||||||
|
//
|
||||||
|
// These are the drawer's actual numbers: a 292px panel, closed at -292, open
|
||||||
|
// at 0. Getting this wrong is the difference between a drawer you can flick
|
||||||
|
// shut and one that argues with you.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const W = 292;
|
||||||
|
const ends = [-W, 0];
|
||||||
|
|
||||||
|
// Released at a standstill, barely open: it falls back closed.
|
||||||
|
assert.equal(snapTarget(-250, 0, ends), -W);
|
||||||
|
// Released at a standstill, mostly open: it completes.
|
||||||
|
assert.equal(snapTarget(-40, 0, ends), 0);
|
||||||
|
|
||||||
|
// The case position alone gets wrong. Dragged nearly all the way open, then
|
||||||
|
// flicked back hard — intent is unmistakably "away", even though the panel
|
||||||
|
// is sitting closer to open than to closed at the instant of release.
|
||||||
|
assert.equal(snapTarget(-30, -1600, ends), -W,
|
||||||
|
'a hard flick must be decided by its direction, not by where it let go');
|
||||||
|
|
||||||
|
// And the mirror: barely peeked open, but thrown forward.
|
||||||
|
assert.equal(snapTarget(-260, 1600, ends), 0,
|
||||||
|
'a flick towards open must be allowed to arrive');
|
||||||
|
|
||||||
|
// Below the flick threshold, momentum still counts — a gentle push that was
|
||||||
|
// clearly going somewhere should not be snapped back to the nearest edge.
|
||||||
|
assert.equal(snapTarget(-160, 180, ends), 0,
|
||||||
|
'a slow push past the midpoint should complete on its projection');
|
||||||
|
|
||||||
|
// Degenerate inputs must not throw; a gesture with nowhere to go stays put.
|
||||||
|
assert.equal(snapTarget(-100, 500, []), -100);
|
||||||
|
assert.equal(snapTarget(-100, 500, [-100]), -100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// rubberband() — resistance, not a wall
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
assert.equal(rubberband(0, 300), 0, 'at the boundary there is no resistance yet');
|
||||||
|
|
||||||
|
// The defining property: the element keeps following, but always less than
|
||||||
|
// the finger. Equal movement would mean no boundary; zero would mean a wall.
|
||||||
|
for (const overshoot of [10, 50, 120, 400, 2000]) {
|
||||||
|
const out = rubberband(overshoot, 300);
|
||||||
|
assert.ok(out > 0 && out < overshoot,
|
||||||
|
`overshoot ${overshoot} must still move the element, but less than the finger`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resistance increases: each extra pixel of finger buys less travel.
|
||||||
|
const gain = (o) => rubberband(o + 1, 300) - rubberband(o, 300);
|
||||||
|
assert.ok(gain(200) < gain(10), 'resistance must grow the further past the edge you drag');
|
||||||
|
|
||||||
|
assert.ok(rubberband(-100, 300) < 0, 'resistance is symmetric');
|
||||||
|
assert.equal(rubberband(100, 0), 0, 'a zero-sized dimension cannot resist anything');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// velocityTracker() — the number handed to the spring on release
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const v = velocityTracker();
|
||||||
|
assert.equal(v.get(0), 0, 'a single sample is not a velocity');
|
||||||
|
|
||||||
|
// 100px over 100ms = 1000px/s, averaged over the window rather than taken
|
||||||
|
// from the last pair of events, which is far too noisy to animate from.
|
||||||
|
v.add(0, 0); v.add(50, 50); v.add(100, 100);
|
||||||
|
assert.ok(Math.abs(v.get(100) - 1000) < 1, 'steady drag must report its true speed');
|
||||||
|
|
||||||
|
// A finger that has come to rest has stopped, whatever the older samples say.
|
||||||
|
// Without this, lifting after a pause throws the element across the screen.
|
||||||
|
assert.equal(v.get(1000), 0, 'a stale history must not resurrect an old velocity');
|
||||||
|
|
||||||
|
v.reset();
|
||||||
|
assert.equal(v.get(0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// spring() — arrives, and only bounces when told to
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
// Critically damped: the default for anything the user did not throw.
|
||||||
|
let peak = -Infinity;
|
||||||
|
const s = spring({
|
||||||
|
from: 0, to: 100,
|
||||||
|
damping: SPRING.move.damping, response: SPRING.move.response,
|
||||||
|
onUpdate: (v) => { peak = Math.max(peak, v); }
|
||||||
|
});
|
||||||
|
run(s);
|
||||||
|
assert.ok(s.done, 'the spring must come to rest, not run forever');
|
||||||
|
assert.equal(s.value, 100, 'and it must rest exactly on the target');
|
||||||
|
assert.ok(peak <= 100.01, `damping 1.0 must not overshoot (peaked at ${peak.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Under-damped: overshoot is the whole point, and it is what a flick earns.
|
||||||
|
let peak = -Infinity;
|
||||||
|
const s = spring({
|
||||||
|
from: 0, to: 100,
|
||||||
|
damping: SPRING.drawer.damping, response: SPRING.drawer.response,
|
||||||
|
velocity: 900,
|
||||||
|
onUpdate: (v) => { peak = Math.max(peak, v); }
|
||||||
|
});
|
||||||
|
run(s);
|
||||||
|
assert.ok(peak > 100, 'damping 0.8 with velocity behind it must overshoot');
|
||||||
|
assert.equal(s.value, 100, 'and still settle on the target');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Velocity handoff: the spring must leave the gesture at the speed the
|
||||||
|
// gesture arrived. Starting from rest is the visible seam between dragging
|
||||||
|
// and animating that separates "fluid" from merely "fine".
|
||||||
|
const slow = spring({ from: 0, to: 100, velocity: 0, onUpdate() {} });
|
||||||
|
const fast = spring({ from: 0, to: 100, velocity: 2000, onUpdate() {} });
|
||||||
|
tick(); tick();
|
||||||
|
assert.ok(fast.value > slow.value,
|
||||||
|
'a spring handed a release velocity must already be ahead after two frames');
|
||||||
|
slow.stop(); fast.stop();
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Interruption and reversal. Re-targeting keeps the live velocity instead of
|
||||||
|
// resetting it to zero, so a reversed gesture bends rather than hitting a
|
||||||
|
// brick wall at the moment the user changed their mind.
|
||||||
|
const s = spring({ from: 0, to: 300, response: 0.5, onUpdate() {} });
|
||||||
|
tick(); tick(); tick();
|
||||||
|
const mid = s.value;
|
||||||
|
const carried = s.velocity;
|
||||||
|
assert.ok(mid > 0 && mid < 300, 'caught mid-flight');
|
||||||
|
assert.ok(carried > 0, 'and still moving');
|
||||||
|
|
||||||
|
s.retarget(0);
|
||||||
|
assert.equal(s.velocity, carried, 'retarget must not zero the velocity');
|
||||||
|
assert.equal(s.value, mid, 'nor teleport the presentation value');
|
||||||
|
run(s);
|
||||||
|
assert.equal(s.value, 0, 'the reversed spring lands on the new target');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// stop() must actually unschedule, or a dismissed drawer keeps burning
|
||||||
|
// frames for the life of the session.
|
||||||
|
const s = spring({ from: 0, to: 100, onUpdate() {} });
|
||||||
|
tick();
|
||||||
|
assert.ok(frames.size > 0, 'a live spring keeps a frame queued');
|
||||||
|
s.stop();
|
||||||
|
assert.equal(frames.size, 0, 'stop() must unschedule the pending frame');
|
||||||
|
tick();
|
||||||
|
assert.equal(frames.size, 0, 'and never queue another');
|
||||||
|
assert.ok(s.done);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// Reduced motion: no travel, but the caller still gets its value. Silently
|
||||||
|
// dropping the update would leave the drawer stuck off-screen.
|
||||||
|
reduceMotion = true;
|
||||||
|
assert.equal(prefersReducedMotion(), true);
|
||||||
|
const seen = [];
|
||||||
|
const s = spring({ from: 0, to: 100, onUpdate: (v) => seen.push(v) });
|
||||||
|
assert.deepEqual(seen, [100], 'with motion reduced the value arrives immediately');
|
||||||
|
assert.ok(s.done, 'and nothing is left animating');
|
||||||
|
assert.equal(frames.size, 0, 'no frame is scheduled at all');
|
||||||
|
|
||||||
|
// Opting out is possible for motion that is not vestibular.
|
||||||
|
const t = spring({ from: 0, to: 100, respectReducedMotion: false, onUpdate() {} });
|
||||||
|
assert.equal(t.done, false, 'respectReducedMotion:false still animates');
|
||||||
|
t.stop();
|
||||||
|
reduceMotion = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Wiring — the primitives are worth nothing sitting in a file nobody imports
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const app = readFileSync(new URL('../src/app.jsx', import.meta.url), 'utf8');
|
||||||
|
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||||
|
const css = readFileSync(new URL('../src/styles/apple-motion.css', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
assert.match(app, /from '\.\/ui\/motion\.js'/, 'app.jsx must use the motion primitives');
|
||||||
|
|
||||||
|
// The drawer is the one true gesture surface in the app. If it goes back to a
|
||||||
|
// display toggle, everything above is decoration.
|
||||||
|
assert.match(app, /onPointerDown: drawerDown/, 'the drawer must be dragged, not toggled');
|
||||||
|
assert.match(app, /snapTarget\(offsetRef\.current, v, \[-w, 0\]/,
|
||||||
|
'the release must be resolved by projection, not by which edge is nearer');
|
||||||
|
assert.match(app, /onPointerMove: drawerMove/);
|
||||||
|
assert.match(app, /onPointerCancel: drawerUp/, 'a cancelled pointer must release the drag too');
|
||||||
|
assert.match(app, /setPointerCapture/, 'tracking must survive the pointer leaving the panel');
|
||||||
|
assert.ok(app.includes("touchAction: 'pan-y'"),
|
||||||
|
'the drawer must hand the vertical axis back to the browser, or its list stops scrolling');
|
||||||
|
assert.ok(!/display: drawerOpen \? 'block' : 'none'/.test(app),
|
||||||
|
'the old display toggle must be gone — it is what made the drawer un-grabbable');
|
||||||
|
|
||||||
|
// Every programmatic scroll goes through the reduced-motion check.
|
||||||
|
assert.ok(!app.includes("behavior: 'smooth'"),
|
||||||
|
"no raw behavior:'smooth' — it must go through scrollBehavior()");
|
||||||
|
assert.match(app, /const scrollBehavior = \(\) =>/);
|
||||||
|
|
||||||
|
// The stylesheet has to load, and it has to load last.
|
||||||
|
assert.match(html, /apple-motion\.css/, 'apple-motion.css must be linked');
|
||||||
|
const idxMotion = html.indexOf('apple-motion.css');
|
||||||
|
const idxComponents = html.indexOf('components.css');
|
||||||
|
assert.ok(idxMotion > idxComponents,
|
||||||
|
'apple-motion.css must come after components.css — it settles arguments on source order');
|
||||||
|
|
||||||
|
// The three accessibility signals, all three of them.
|
||||||
|
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
|
||||||
|
assert.match(css, /@media \(prefers-reduced-transparency: reduce\)/);
|
||||||
|
assert.match(css, /@media \(prefers-contrast: more\)/);
|
||||||
|
|
||||||
|
// Press feedback exists and lands on :active, which fires on pointer-down.
|
||||||
|
assert.match(css, /button:not\(:disabled\):active/, 'controls must acknowledge the press itself');
|
||||||
|
assert.match(css, /touch-action: manipulation/, 'the ~300ms tap delay must be dropped');
|
||||||
|
|
||||||
|
// Tracking is a scale, not a constant.
|
||||||
|
const tracking = [...css.matchAll(/letter-spacing: (-?[\d.]+)em/g)].map((m) => Number(m[1]));
|
||||||
|
assert.ok(tracking.some((t) => t < -0.01), 'display sizes must tighten');
|
||||||
|
assert.ok(tracking.some((t) => t > 0), 'small sizes must open up');
|
||||||
|
|
||||||
|
console.log('apple-motion: ok');
|
||||||
Reference in New Issue
Block a user