Pair content-visibility with its render-ahead observer in one component

This commit is contained in:
Coracle-Bot 2026-09-09 20:53:25 +00:00
parent 1485e290e9
commit dd7c15153e
14 changed files with 96 additions and 24 deletions

View file

@ -4,6 +4,7 @@
import {getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {Article} from "@welshman/domain" import {Article} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ArticleActions from "@app/components/ArticleActions.svelte" import ArticleActions from "@app/components/ArticleActions.svelte"
@ -27,7 +28,7 @@
const image = $derived(article.image()) const image = $derived(article.image())
</script> </script>
<div data-component="ArticleItem" class="cv relative w-full card card-interactive"> <Cv data-component="ArticleItem" class="relative w-full card card-interactive">
<!-- An overlay rather than a wrapper: the card carries a profile button and the room and action <!-- An overlay rather than a wrapper: the card carries a profile button and the room and action
links, and none of those may sit inside an anchor. --> links, and none of those may sit inside an anchor. -->
<Link class="absolute inset-0 rounded-2xl" href={path} aria-label={title || "Untitled"} /> <Link class="absolute inset-0 rounded-2xl" href={path} aria-label={title || "Untitled"} />
@ -58,4 +59,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </Cv>

View file

@ -2,6 +2,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {getAddress, tagValue, tagSpec} from "@welshman/util" import {getAddress, tagValue, tagSpec} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import CalendarEventActions from "@app/components/CalendarEventActions.svelte" import CalendarEventActions from "@app/components/CalendarEventActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte" import CalendarEventHeader from "@app/components/CalendarEventHeader.svelte"
@ -22,7 +23,7 @@
const h = $derived(tagValue(tagSpec("h"), event.tags)) const h = $derived(tagValue(tagSpec("h"), event.tags))
</script> </script>
<Link class="cv relative flex flex-col gap-3 card card-interactive w-full" href={path}> <Cv tag={Link} class="relative flex flex-col gap-3 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" /> <UnreadDot {path} class="absolute right-3 top-3" />
<CalendarEventHeader {event} /> <CalendarEventHeader {event} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
@ -34,4 +35,4 @@
</span> </span>
<CalendarEventActions showActivity {url} {event} {context} /> <CalendarEventActions showActivity {url} {event} {context} />
</div> </div>
</Link> </Cv>

View file

@ -4,6 +4,7 @@
import {getAddress} from "@welshman/util" import {getAddress} from "@welshman/util"
import {Classified} from "@welshman/domain" import {Classified} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import CurrencySymbol from "@lib/components/CurrencySymbol.svelte" import CurrencySymbol from "@lib/components/CurrencySymbol.svelte"
import {reader} from "@app/core" import {reader} from "@app/core"
import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte" import ContentLinkBlock from "@app/components/ContentLinkBlock.svelte"
@ -31,7 +32,7 @@
const price = $derived(classified.price()) const price = $derived(classified.price())
</script> </script>
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}> <Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" /> <UnreadDot {path} class="absolute right-3 top-3" />
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
@ -64,4 +65,4 @@
</span> </span>
<ClassifiedActions showActivity {url} {event} {context} /> <ClassifiedActions showActivity {url} {event} {context} />
</div> </div>
</Link> </Cv>

View file

@ -2,6 +2,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {ZapGoal} from "@welshman/domain" import {ZapGoal} from "@welshman/domain"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import {reader} from "@app/core" import {reader} from "@app/core"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
@ -28,7 +29,7 @@
const h = goal.room() const h = goal.room()
</script> </script>
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}> <Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" /> <UnreadDot {path} class="absolute right-3 top-3" />
<p class="text-2xl">{title}</p> <p class="text-2xl">{title}</p>
<Content <Content
@ -47,4 +48,4 @@
</span> </span>
<GoalActions showActivity {url} {event} {context} /> <GoalActions showActivity {url} {event} {context} />
</div> </div>
</Link> </Cv>

View file

@ -32,7 +32,7 @@
let muted = $state($isEventMuted(event)) let muted = $state($isEventMuted(event))
</script> </script>
<div class="flex flex-col gap-2 {restProps.class}" style={restProps.style}> <div {...restProps} class={cx("flex flex-col gap-2", restProps.class)}>
{#if muted} {#if muted}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex gap-2 relative"> <div class="flex gap-2 relative">

View file

@ -6,6 +6,7 @@
import SmileCircle from "@assets/icons/smile-circle.svg?dataurl" import SmileCircle from "@assets/icons/smile-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import EmojiButton from "@lib/components/EmojiButton.svelte" import EmojiButton from "@lib/components/EmojiButton.svelte"
import Cv from "@lib/components/Cv.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import {publishReaction, retractReaction} from "@app/reactions" import {publishReaction, retractReaction} from "@app/reactions"
import NoteCard from "@app/components/NoteCard.svelte" import NoteCard from "@app/components/NoteCard.svelte"
@ -33,7 +34,7 @@
const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []}) const onEmoji = (emoji: NativeEmoji) => createReaction({content: emoji.unicode, tags: []})
</script> </script>
<NoteCard {event} {url} class="cv card card-interactive"> <Cv tag={NoteCard} {event} {url} class="card card-interactive">
<NoteContent {event} expandMode="inline" /> <NoteContent {event} expandMode="inline" />
<div class="flex w-full justify-between gap-2"> <div class="flex w-full justify-between gap-2">
<ReactionSummary <ReactionSummary
@ -52,4 +53,4 @@
</ReactionSummary> </ReactionSummary>
{@render children?.()} {@render children?.()}
</div> </div>
</NoteCard> </Cv>

View file

@ -2,6 +2,7 @@
import UserCircle from "@assets/icons/user-circle.svg?dataurl" import UserCircle from "@assets/icons/user-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileAbout from "@app/components/ProfileAbout.svelte" import ProfileAbout from "@app/components/ProfileAbout.svelte"
import ProfileBadges from "@app/components/ProfileBadges.svelte" import ProfileBadges from "@app/components/ProfileBadges.svelte"
@ -15,7 +16,7 @@
const {pubkey, url}: Props = $props() const {pubkey, url}: Props = $props()
</script> </script>
<div class="cv card card-interactive flex flex-col gap-4"> <Cv class="card card-interactive flex flex-col gap-4">
<div class="flex justify-between"> <div class="flex justify-between">
<Profile {pubkey} {url} /> <Profile {pubkey} {url} />
<Link href={makeProfilePath(pubkey)} class="button button-primary hidden sm:flex"> <Link href={makeProfilePath(pubkey)} class="button button-primary hidden sm:flex">
@ -29,4 +30,4 @@
<Icon icon={UserCircle} /> <Icon icon={UserCircle} />
View Profile View Profile
</Link> </Link>
</div> </Cv>

View file

@ -2,6 +2,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import NoteContent from "@app/components/NoteContent.svelte" import NoteContent from "@app/components/NoteContent.svelte"
import PollActions from "@app/components/PollActions.svelte" import PollActions from "@app/components/PollActions.svelte"
import type {FeedContext} from "@app/feeds" import type {FeedContext} from "@app/feeds"
@ -22,7 +23,7 @@
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
</script> </script>
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}> <Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" /> <UnreadDot {path} class="absolute right-3 top-3" />
<NoteContent {event} {url} /> <NoteContent {event} {url} />
<div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row"> <div class="flex w-full flex-col items-end justify-between gap-2 sm:flex-row">
@ -34,4 +35,4 @@
</span> </span>
<PollActions showActivity {url} {event} {context} /> <PollActions showActivity {url} {event} {context} />
</div> </div>
</Link> </Cv>

View file

@ -7,6 +7,7 @@
import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl" import AltArrowRight from "@assets/icons/alt-arrow-right.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Cv from "@lib/components/Cv.svelte"
import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte" import NoteContentMinimal from "@app/components/NoteContentMinimal.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte" import RoomNameWithImage from "@app/components/RoomNameWithImage.svelte"
@ -25,7 +26,7 @@
const onClick = () => navigate(h ? makeRoomPath(url, h) : makeSpaceChatPath(url)) const onClick = () => navigate(h ? makeRoomPath(url, h) : makeSpaceChatPath(url))
</script> </script>
<Button class="cv card card-interactive" onclick={onClick}> <Cv tag={Button} class="card card-interactive" onclick={onClick}>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm">
{#if h} {#if h}
@ -55,4 +56,4 @@
</Button> </Button>
</div> </div>
</div> </div>
</Button> </Cv>

View file

@ -4,6 +4,7 @@
import {LOCAL_RELAY_URL} from "@welshman/net" import {LOCAL_RELAY_URL} from "@welshman/net"
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import Card from "@lib/components/Card.svelte" import Card from "@lib/components/Card.svelte"
import Cv from "@lib/components/Cv.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Profile from "@app/components/Profile.svelte" import Profile from "@app/components/Profile.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
@ -36,7 +37,7 @@
} }
</script> </script>
<Card sm class="cv flex flex-col gap-4"> <Cv tag={Card} sm class="flex flex-col gap-4">
<div class="flex justify-between"> <div class="flex justify-between">
<div> <div>
<ProfileName pubkey={event.pubkey} {url} /> <ProfileName pubkey={event.pubkey} {url} />
@ -80,4 +81,4 @@
<Profile pubkey={ptag[1]} /> <Profile pubkey={ptag[1]} />
{/if} {/if}
</div> </div>
</Card> </Cv>

View file

@ -5,6 +5,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {COMMENT, tagSpec, tagValue} from "@welshman/util" import {COMMENT, tagSpec, tagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import ProfileCircle from "@app/components/ProfileCircle.svelte" import ProfileCircle from "@app/components/ProfileCircle.svelte"
import ProfileName from "@app/components/ProfileName.svelte" import ProfileName from "@app/components/ProfileName.svelte"
import UnreadDot from "@app/components/UnreadDot.svelte" import UnreadDot from "@app/components/UnreadDot.svelte"
@ -30,9 +31,10 @@
</script> </script>
{#if stacked} {#if stacked}
<Link <Cv
tag={Link}
href={path} href={path}
class="cv hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors"> class="hover:bg-surface-less flex w-full flex-col gap-2 border-b border-solid border-line px-4 py-3 text-left text-sm transition-colors">
<p class="truncate font-medium"> <p class="truncate font-medium">
<UnreadDot {path} class="mr-1" />{title || "Untitled thread"} <UnreadDot {path} class="mr-1" />{title || "Untitled thread"}
</p> </p>
@ -46,7 +48,7 @@
<span>{replyCount} {replyCount === 1 ? "reply" : "replies"}</span> <span>{replyCount} {replyCount === 1 ? "reply" : "replies"}</span>
<span>{formatTimestamp(lastActive)}</span> <span>{formatTimestamp(lastActive)}</span>
</div> </div>
</Link> </Cv>
{:else} {:else}
<tr <tr
onclick={onClick} onclick={onClick}

View file

@ -3,6 +3,7 @@
import type {TrustedEvent} from "@welshman/util" import type {TrustedEvent} from "@welshman/util"
import {tagSpec, tagValue} from "@welshman/util" import {tagSpec, tagValue} from "@welshman/util"
import Link from "@lib/components/Link.svelte" import Link from "@lib/components/Link.svelte"
import Cv from "@lib/components/Cv.svelte"
import Content from "@app/components/Content.svelte" import Content from "@app/components/Content.svelte"
import ProfileLink from "@app/components/ProfileLink.svelte" import ProfileLink from "@app/components/ProfileLink.svelte"
import ThreadActions from "@app/components/ThreadActions.svelte" import ThreadActions from "@app/components/ThreadActions.svelte"
@ -24,7 +25,7 @@
const h = tagValue(tagSpec("h"), event.tags) const h = tagValue(tagSpec("h"), event.tags)
</script> </script>
<Link class="cv relative flex flex-col gap-2 card card-interactive w-full" href={path}> <Cv tag={Link} class="relative flex flex-col gap-2 card card-interactive w-full" href={path}>
<UnreadDot {path} class="absolute right-3 top-3" /> <UnreadDot {path} class="absolute right-3 top-3" />
{#if title} {#if title}
<div class="flex w-full items-center justify-between gap-2"> <div class="flex w-full items-center justify-between gap-2">
@ -49,4 +50,4 @@
</span> </span>
<ThreadActions showActivity {url} {event} {context} /> <ThreadActions showActivity {url} {event} {context} />
</div> </div>
</Link> </Cv>

View file

@ -0,0 +1,58 @@
<script lang="ts" module>
import type {Maybe} from "@welshman/lib"
let instance: Maybe<IntersectionObserver>
/** Browsers only paint a `content-visibility: auto` element within half a viewport of the scroll
* position, which a fast scroll outruns, leaving empty cards behind. There is no css control over
* that distance, so the element is painted two viewports ahead instead. The observer has to watch
* the element carrying `cv` — a descendant of one is inside the subtree being skipped, which is
* why the class and the attachment live in the same component. */
const renderAhead = (element: HTMLElement) => {
const observer = (instance ||= new IntersectionObserver(
entries => {
for (const entry of entries) {
const target = entry.target as HTMLElement
target.style.contentVisibility = entry.isIntersecting ? "visible" : ""
}
},
{rootMargin: "200% 0px"},
))
observer.observe(element)
return () => observer.unobserve(element)
}
</script>
<script lang="ts">
import type {Component, Snippet} from "svelte"
import cx from "classnames"
// The root of anything rendered in a long list. `cv` lets the browser skip an offscreen one,
// and `renderAhead` is what keeps a fast scroll from outrunning that; `tag` takes whatever the
// item was already using as its root, so nothing gains a wrapper element and everything else
// passes through to it.
type Props = {
children?: Snippet
tag?: string | Component<any>
class?: string
[key: string]: any
}
const {children, tag = "div", ...restProps}: Props = $props()
const className = $derived(cx("cv", restProps.class))
</script>
{#if typeof tag === "string"}
<svelte:element this={tag} {...restProps} class={className} {@attach renderAhead}>
{@render children?.()}
</svelte:element>
{:else}
{@const Host = tag}
<Host {...restProps} class={className} {@attach renderAhead}>
{@render children?.()}
</Host>
{/if}

View file

@ -99,6 +99,8 @@
@utility superheading { @utility superheading {
@apply font-heading text-center text-4xl font-extrabold; @apply font-heading text-center text-4xl font-extrabold;
} }
/* Applied by Cv.svelte, which pairs it with the observer that paints ahead of a fast scroll.
Nothing else should reach for it. */
@utility cv { @utility cv {
content-visibility: auto; content-visibility: auto;
} }