Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions patches/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
# Virtua 0.51.0 macOS WebKit correction boundary
# Virtua 0.51.0 scroll correction boundaries

The application imports the React ESM entry (`virtua` → `lib/index.js`) from
`src/features/messages/ChannelTimeline.tsx`. Only that entry's element scroller is
patched; CommonJS, window scrolling, and other-framework exports are untouched.
Keep the dependency pinned to 0.51.0 and review the patch plus version-coupled
installed-bundle tests before upgrading or adding a different import.

## Fractional end offsets

Text layout can produce fractional row heights. WebKit can truncate an absolute
scroll offset before clamping it, leaving the final row partly outside the
viewport even when Virtua requests the end of the list. A focused notification
target then correctly remains unread under the full-row visibility policy.

The element driver rounds end-boundary targets upward before RTL normalization,
for both imperative scrolling and automatic resize compensation. The browser
clamps that target to its actual scroll range. Interior reading offsets remain
fractional, and the existing scheduling, focus and dwell rules are unchanged.
Installed-driver regressions cover both entry points and RTL; a browser regression
sets a fractional list height explicitly and requires the final row to be fully
visible without a pixel tolerance.

## Failure and chosen boundary

In a system WKWebView, native momentum can overwrite an instant programmatic
Expand Down Expand Up @@ -39,10 +54,10 @@ gesture must continue to work; sustained real-history/media acceptance must asse
whether repeated braking is acceptable. No wheel ownership, permanent scrolling
CSS, forced layout, alternate store sizing, or new scroll scheduler is introduced.

The platform predicate requires MacIntel and Apple vendor, excluding Virtua's iOS
detector (including desktop-mode iPad). Chrome/Firefox, non-Mac WebKit and iOS keep
existing policy. Store/layout/observer timing and imperative smooth/instant
navigation remain stock. Scheduler-driven reveal/restore/bottom navigation is a
The momentum-interruption predicate requires MacIntel and Apple vendor, excluding
Virtua's iOS detector (including desktop-mode iPad). Chrome/Firefox, non-Mac WebKit
and iOS keep existing momentum policy. Store/layout/observer timing and imperative
smooth/instant scheduling remain stock. Scheduler-driven reveal/restore/bottom navigation is a
separate acceptance path, not implicitly repaired by the automatic-correction fix.
Native reveal controls showed one/two transient blank interior source frames
before immediate recovery, despite valid sampled DOM coverage. This remaining
Expand All @@ -61,7 +76,7 @@ bin/pnpm test:browser history-loading.spec.mjs image-scroll.spec.mjs initial-pos
--project chromium --project webkit --no-deps --workers=1
```

The 13 driver/store/observer contracts evaluate the installed React ESM, not a
The driver/store/observer contracts evaluate the installed React ESM, not a
copied implementation. They cover active and inferred-idle corrections, zero
jumps, positive/negative measurements, absolute edges, horizontal RTL, overlapping
restoration, CSS priority, disposal/remount, later declarations, platform controls,
Expand Down
41 changes: 18 additions & 23 deletions patches/virtua@0.51.0.patch
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
diff --git a/lib/index.js b/lib/index.js
index a183cc7..a892a1c 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -7,1 +7,5 @@
Expand All @@ -7,12 +8,12 @@ diff --git a/lib/index.js b/lib/index.js
+// WebKit can restore a stale native scroll offset after an instant correction.
+// Interrupt only the corrected axis; observer-inferred idle is not native idle.
+const isMacWebKit = () => typeof navigator !== "undefined" && !b() && navigator.platform === "MacIntel" && navigator.vendor === "Apple Computer, Inc.";
@@ -249,7 +253,22 @@
o && o();
} ];
}, E = (e, t) => {
@@ -252 +256,19 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
- let n, o, r = $(), i = !1;
+ let n, o, r = $(), i = !1, restoreOverflow;
+ // Scroll APIs can truncate fractional offsets. Reach the whole edge without
+ // quantizing interior reading positions or changing RTL normalization.
+ const edgeOffset = offset => offset >= e.O() + e.t() - e.o() ? Math.ceil(offset) : offset;
+ const interruptMomentum = () => {
+ // Repeated corrections in one task share the original declaration.
+ restoreOverflow && restoreOverflow();
Expand All @@ -28,28 +29,22 @@ diff --git a/lib/index.js b/lib/index.js
+ }, timer = p(restore);
+ restoreOverflow = restore;
+ };
const s = t ? "scrollLeft" : "scrollTop", l = t ? "left" : "top", c = t ? "overflowX" : "overflowY", [f, a] = O(e, () => r[0], (e, t) => {
n.scrollTo({
[l]: T(e, i),
@@ -266,7 +285,8 @@
return {
D(f, u = f.parentElement) {
h.A(n = u), t && (i = "rtl" === getComputedStyle(u).direction), o = M(e, u, t, () => T(u[s], i), (t, n, o) => {
@@ -255 +277 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
- [l]: T(e, i),
+ [l]: T(edgeOffset(e), i),
@@ -269 +291,2 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
- if (o) {
+ if (isMacWebKit()) interruptMomentum();
+ else if (o) {
const e = u.style, t = e[c];
e[c] = "hidden", p(() => {
e[c] = t;
@@ -283,7 +303,7 @@
}), r[1](!0);
},
_() {
@@ -277 +300 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
- [l]: T(r, i),
+ [l]: T(edgeOffset(r), i),
@@ -286 +309 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
- h.X(), o && o.X(), r[1](!1), r = $();
+ restoreOverflow && restoreOverflow(), h.X(), o && o.X(), r[1](!1), r = $();
},
P: (e, t) => (d.set(e, t), h.A(e), () => {
d.delete(e), h.B(e);
@@ -799,2 +819,1 @@
export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid };
@@ -799,2 +822 @@ var {min: a, max: u, abs: d, floor: h} = Math, g = (e, t, n) => a(n, u(t, e)), v
-export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid };
-//# sourceMappingURL=index.js.map
\ No newline at end of file
+export { G as VList, j as Virtualizer, U as WindowVirtualizer, N as experimental_VGrid };
\ No newline at end of file
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 4 additions & 9 deletions scripts/design-system/check-type.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* and read as enterprise chrome, and tracking is corrected per ramp step.
* 3. No size role paired with a leading utility. A role carries its own line
* height; overriding it is how two identical labels drift apart.
* 4. No weight outside 400 and 600. `font-semibold` is bold.
* 4. No weight outside 400 and 500. `font-semibold` aliases Medium.
*
* What this guard is for: an agent building a screen has no reason to prefer
* `font-medium` over `font-semibold`, so left unguided it picks either, and the
Expand Down Expand Up @@ -125,14 +125,9 @@ const RULES = [
},
{
id: "off-ramp-weight",
// The system has two weights: 400 (content) and 600 (structure and
// emphasis). 500 was measured against 400 at body size and does not read as
// intent in a scanned list — it is heavy enough to muddy a column and too
// subtle to signal. 700 was rejected as louder than anything Buzz needs.
// The rest have never had a use.
//
// `font-semibold` and `font-normal` are absent from this list on purpose:
// they are the two legal weights.
// Regular (400) is for content; Medium (500) is for labels and structure.
// font-normal, font-medium and the font-semibold compatibility alias are
// valid utilities for these two weights.
pattern: /\bfont-(?:thin|extralight|light|bold|extrabold|black)\b/g,
message:
"off-ramp font weight — the system is 400 and 500. Emphasis is font-medium; legacy font-semibold resolves to 500. If a one-off genuinely needs another weight, add it to OVERRIDES with a reason.",
Expand Down
23 changes: 12 additions & 11 deletions src/app/AppearanceSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Button } from "../shared/design-system/ui/Button";
import { useSyncExternalStore } from "react";
import { Moon, Sun } from "lucide-react";
import { IconMoon as Moon, IconSun as Sun } from "@tabler/icons-react";
import type { Appearance } from "../shared/theme/service";

/** Native radios provide one Tab stop and standard arrow-key selection. */
Expand Down Expand Up @@ -56,47 +57,47 @@ export function AppearanceSettings({ appearance }: { appearance: Appearance }) {
Resize text without zooming the window. Saved on this device.
</p>
<div className="flex flex-wrap items-center gap-3">
<button
<Button
type="button"
aria-label="Decrease text size"
disabled={fontScale <= 0.8}
onClick={() => appearance.setFontScale(fontScale - 0.1)}
>
</button>
</Button>
<output aria-label="Text size">
{Math.round(fontScale * 100)}%
</output>
<button
<Button
type="button"
aria-label="Increase text size"
disabled={fontScale >= 2}
onClick={() => appearance.setFontScale(fontScale + 0.1)}
>
+
</button>
<button type="button" onClick={() => appearance.setFontScale(1)}>
</Button>
<Button type="button" onClick={() => appearance.setFontScale(1)}>
Reset text size
</button>
</Button>
</div>
</fieldset>
{fontError && (
<div role="alert" className="notice mb-0">
<p>{fontError}</p>
<button
<Button
type="button"
onClick={() => appearance.setFontScale(fontScale)}
>
Retry saving text size
</button>
</Button>
</div>
)}
{error && (
<div role="alert" className="notice mb-0">
<p>{error}</p>
<button type="button" onClick={() => appearance.setMode(mode)}>
<Button type="button" onClick={() => appearance.setMode(mode)}>
Retry saving appearance
</button>
</Button>
</div>
)}
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/app/DeveloperSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Button } from "../shared/design-system/ui/Button";
import { useEffect, useState } from "react";
import type { RelayData } from "../features/relay/service";

Expand Down Expand Up @@ -97,13 +98,13 @@ export function DeveloperSettings({ relay }: { relay: RelayData }) {
Clears cached channels, messages, and media. Account, relay, and
sidebar settings are kept.
</p>
<button
<Button
type="button"
disabled={clearing}
onClick={() => void clearCache()}
>
{clearing ? "Clearing…" : "Clear cache"}
</button>
</Button>
{status && (
<p role="status" className="m-0 text-body-sm text-muted">
{status}
Expand Down
15 changes: 15 additions & 0 deletions src/app/NotificationSettings.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.root [role="switch"] {
flex-shrink: 0;
}

.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}

.actions > button {
height: auto;
min-height: var(--size-control);
padding-block: var(--space-2);
}
Loading
Loading