diff --git a/patches/README.md b/patches/README.md index b7403138..150e8cd0 100644 --- a/patches/README.md +++ b/patches/README.md @@ -1,4 +1,4 @@ -# 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 @@ -6,6 +6,21 @@ 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 @@ -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 @@ -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, diff --git a/patches/virtua@0.51.0.patch b/patches/virtua@0.51.0.patch index a1d36783..2a45a14f 100644 --- a/patches/virtua@0.51.0.patch +++ b/patches/virtua@0.51.0.patch @@ -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 @@ @@ -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(); @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db0096c5..f7c2241f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false patchedDependencies: - virtua@0.51.0: 14ff685d9bc68b34d1a7c6b0042d6000e4b07a9746088fc3842c89c4a995ca9d + virtua@0.51.0: 8ce61fc4aaeefa37261c28b45657b9c53c03a364ea9f64fb29e974d9c44cff6a importers: @@ -79,7 +79,7 @@ importers: version: 4.0.1 virtua: specifier: 0.51.0 - version: 0.51.0(patch_hash=14ff685d9bc68b34d1a7c6b0042d6000e4b07a9746088fc3842c89c4a995ca9d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 0.51.0(patch_hash=8ce61fc4aaeefa37261c28b45657b9c53c03a364ea9f64fb29e974d9c44cff6a)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) yaml: specifier: 2.8.3 version: 2.8.3 @@ -3472,7 +3472,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.51.0(patch_hash=14ff685d9bc68b34d1a7c6b0042d6000e4b07a9746088fc3842c89c4a995ca9d)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + virtua@0.51.0(patch_hash=8ce61fc4aaeefa37261c28b45657b9c53c03a364ea9f64fb29e974d9c44cff6a)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): optionalDependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) diff --git a/scripts/design-system/check-type.mjs b/scripts/design-system/check-type.mjs index e2525c04..1f48c963 100644 --- a/scripts/design-system/check-type.mjs +++ b/scripts/design-system/check-type.mjs @@ -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 @@ -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.", diff --git a/src/app/AppearanceSettings.tsx b/src/app/AppearanceSettings.tsx index 8d7032b7..298adf2c 100644 --- a/src/app/AppearanceSettings.tsx +++ b/src/app/AppearanceSettings.tsx @@ -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. */ @@ -56,47 +57,47 @@ export function AppearanceSettings({ appearance }: { appearance: Appearance }) { Resize text without zooming the window. Saved on this device.
{fontError}
- +{error}
-
{status}
diff --git a/src/app/NotificationSettings.module.css b/src/app/NotificationSettings.module.css
new file mode 100644
index 00000000..37e9bf99
--- /dev/null
+++ b/src/app/NotificationSettings.module.css
@@ -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);
+}
diff --git a/src/app/NotificationSettings.tsx b/src/app/NotificationSettings.tsx
index 706f6729..b81fef20 100644
--- a/src/app/NotificationSettings.tsx
+++ b/src/app/NotificationSettings.tsx
@@ -1,5 +1,8 @@
+import { Switch } from "../shared/design-system/ui/Switch";
+import { Button } from "../shared/design-system/ui/Button";
import { useSyncExternalStore } from "react";
import type { NotificationsService } from "../features/notifications/service";
+import styles from "./NotificationSettings.module.css";
export function NotificationSettings({
notifications,
@@ -12,19 +15,24 @@ export function NotificationSettings({
);
const { preferences, permission } = state;
return (
-
Choices are saved for this account on this device. System permission
is separate.
{state.requesting
@@ -40,29 +48,29 @@ export function NotificationSettings({
: "Allow notifications to receive alerts."}
Sound uses the system default where supported. Turning it off
@@ -85,14 +95,14 @@ export function NotificationSettings({
Notifications
-