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.

- + {Math.round(fontScale * 100)}% - - + +
{fontError && (

{fontError}

- +
)} {error && (

{error}

- +
)} diff --git a/src/app/DeveloperSettings.tsx b/src/app/DeveloperSettings.tsx index 2e9c66d4..ec9fd2b8 100644 --- a/src/app/DeveloperSettings.tsx +++ b/src/app/DeveloperSettings.tsx @@ -1,3 +1,4 @@ +import { Button } from "../shared/design-system/ui/Button"; import { useEffect, useState } from "react"; import type { RelayData } from "../features/relay/service"; @@ -97,13 +98,13 @@ export function DeveloperSettings({ relay }: { relay: RelayData }) { Clears cached channels, messages, and media. Account, relay, and sidebar settings are kept.

- + {status && (

{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 ( -

+

Notifications

-
+

Choices are saved for this account on this device. System permission is separate.

- notifications.updatePreferences({ enabled })} + onCheckedChange={(enabled) => + notifications.updatePreferences({ enabled }) + } />

{state.requesting @@ -40,29 +48,29 @@ export function NotificationSettings({ : "Allow notifications to receive alerts."}

{!state.systemManaged && ( -
+
{permission === "default" && ( - + )} - +
)} - + onCheckedChange={(notifyWhileViewing) => notifications.updatePreferences({ notifyWhileViewing }) } /> @@ -74,10 +82,12 @@ export function NotificationSettings({

) : ( <> - notifications.updatePreferences({ sound })} + onCheckedChange={(sound) => + notifications.updatePreferences({ sound }) + } />

Sound uses the system default where supported. Turning it off @@ -85,14 +95,14 @@ export function NotificationSettings({

)} -
+
Notify me about {state.categories.map(({ key, label }) => ( - + onCheckedChange={(enabled) => notifications.updatePreferences({ categories: { ...preferences.categories, [key]: enabled }, }) @@ -107,18 +117,18 @@ export function NotificationSettings({ {state.preferencesError && (

{state.preferencesError}

- {" "} - +
)} {state.error && ( @@ -130,25 +140,3 @@ export function NotificationSettings({
); } -function Toggle({ - label, - checked, - onChange, -}: { - label: string; - checked: boolean; - onChange(checked: boolean): void; -}) { - return ( - - ); -} diff --git a/src/app/PluginImport.tsx b/src/app/PluginImport.tsx index 7c52fc74..4cd29cb7 100644 --- a/src/app/PluginImport.tsx +++ b/src/app/PluginImport.tsx @@ -1,5 +1,9 @@ +import { Button } from "../shared/design-system/ui/Button"; import { useEffect, useRef, useState } from "react"; -import { FolderOpen, GitBranch } from "lucide-react"; +import { + IconFolderOpen as FolderOpen, + IconGitBranch as GitBranch, +} from "@tabler/icons-react"; import type { PluginManager } from "../plugins/manager"; import type { Catalog, ImportPreview } from "../plugins/types"; @@ -82,23 +86,21 @@ export function PluginImport({ return (
- - +
{gitForm && (
- +
+ +
)}

@@ -229,7 +232,7 @@ export function PluginImport({ )}

{candidate && ( - + )} - +
)} diff --git a/src/app/ProfileSettings.tsx b/src/app/ProfileSettings.tsx index aba9168e..4a0ddba0 100644 --- a/src/app/ProfileSettings.tsx +++ b/src/app/ProfileSettings.tsx @@ -1,3 +1,4 @@ +import { Button } from "../shared/design-system/ui/Button"; import { useState, useSyncExternalStore } from "react"; import type { Communities, @@ -68,14 +69,14 @@ export function ProfileSettings({ communities }: { communities: Communities }) {

)}
- - +

{saved ? "Profile updated." : ""}

diff --git a/src/app/Settings.tsx b/src/app/Settings.tsx index 54f02033..2592c961 100644 --- a/src/app/Settings.tsx +++ b/src/app/Settings.tsx @@ -1,7 +1,15 @@ +import { Button } from "../shared/design-system/ui/Button"; +import { Switch } from "../shared/design-system/ui/Switch"; import { useEffect, useState, useSyncExternalStore } from "react"; import { RecoveryScreen } from "./RecoveryScreen"; import styles from "./Settings.module.css"; -import { Blocks, UserRound, Palette, Bell, Wrench } from "lucide-react"; +import { + IconBlocks as Blocks, + IconUser as UserRound, + IconPalette as Palette, + IconBell as Bell, + IconTool as Wrench, +} from "@tabler/icons-react"; import type { PluginManager } from "../plugins/manager"; import type { Communities } from "../features/communities/service"; import { PluginImport } from "./PluginImport"; @@ -91,7 +99,7 @@ export function Settings({ else setSelected(id); }} > -
)} @@ -174,11 +182,7 @@ export function Settings({ >
-

@@ -192,34 +196,28 @@ export function Settings({

- + /> {plugin.previous && ( - + )} {plugin.source === "external" && ( {orderPages(pages).map((page) => { @@ -85,7 +85,7 @@ export function AppShell({ aria-current={selected === page.key ? "page" : undefined} onClick={() => onSelect(page.key)} > -
{destinations.map(({ key, label, icon: Icon }) => ( diff --git a/src/app/shell/PanelLaunchers.tsx b/src/app/shell/PanelLaunchers.tsx index 474273a7..9401b13b 100644 --- a/src/app/shell/PanelLaunchers.tsx +++ b/src/app/shell/PanelLaunchers.tsx @@ -1,5 +1,6 @@ +import { IconButton } from "../../shared/design-system/ui/IconButton"; import { useState } from "react"; -import { PanelRight } from "lucide-react"; +import { IconLayoutSidebarRight as PanelRight } from "@tabler/icons-react"; import type { RegisteredPanel } from "../../features/panels/service"; export function PanelLaunchers({ @@ -14,31 +15,33 @@ export function PanelLaunchers({ return panels .filter((panel) => panel.launcher) .map((panel) => ( - + icon={ + + } + /> )); } function LauncherIcon({ src }: { src: string }) { const [failed, setFailed] = useState(false); return failed ? ( -