From 39ece5a0b6b7de7b4e7967fed8f40e289c28c8cc Mon Sep 17 00:00:00 2001 From: eti Date: Thu, 6 Aug 2026 17:56:01 +0300 Subject: [PATCH] web: slide the content when the active tab changes TabContent publishes the motion as custom properties on the region it wraps, and app.css decides which boxes actually travel, so a panel welded to the bar above it holds still while its body moves. The repository tabs, the repository settings sub-nav, the profile tabs and /settings all run it. On the profile the region wraps only the content column, because the profile card beside it is the same on every tab. Followers and following are not in the profile tab list, so those two swaps still change the content with no motion. Signed-off-by: eti --- web/src/app.css | 51 +++++++- .../lib/components/profile/ProfileTabs.svelte | 30 +---- web/src/lib/components/profile/tabs/defs.ts | 34 ++++++ web/src/lib/components/repo/RepoTabs.svelte | 32 +---- web/src/lib/components/repo/tabs.ts | 36 ++++++ .../components/settings/SettingsPanel.svelte | 3 + web/src/lib/components/ui/TabContent.svelte | 114 ++++++++++++++++++ web/src/lib/components/ui/TabPanel.svelte | 22 +++- web/src/lib/components/ui/Tabs.svelte | 58 +++++---- web/src/routes/[handle]/+layout.svelte | 13 +- .../routes/[handle]/[repo]/+layout@.svelte | 8 +- .../[handle]/[repo]/settings/+layout.svelte | 9 +- web/src/routes/settings/+layout.svelte | 19 ++- 13 files changed, 335 insertions(+), 94 deletions(-) create mode 100644 web/src/lib/components/profile/tabs/defs.ts create mode 100644 web/src/lib/components/repo/tabs.ts create mode 100644 web/src/lib/components/ui/TabContent.svelte diff --git a/web/src/app.css b/web/src/app.css index e77fabe1..de08cf6d 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -253,18 +253,61 @@ line-height: 1.25rem; } +/* One tab's content giving way to another's. TabContent publishes the motion as + custom properties on the region it wraps; the rules below decide which boxes + actually travel, because a panel welded to the tab bar above it has to hold + still — a border sliding out from under the active tab breaks the join. */ +@keyframes tab-in { + from { + translate: var(--tab-from, 0); + opacity: 0; + } +} + +@layer components { + /* everything a tab route lays down travels as one, so a page carrying content + below its card — an overview's readme, an issue list — moves with it. only a + panel's own box is held back, which means everything inside it still travels: + a settings sidebar is chrome for its own sub-nav but content for this move, + arriving on screen for the first time along with the rest */ + [data-tab-motion] > *:not([data-tab-panel]), + [data-tab-motion] > [data-tab-panel] > * { + translate: var(--tab-shift, 0); + opacity: var(--tab-fade, 1); + transition: + translate var(--tab-out-duration, 0ms) var(--tab-out-ease, linear), + opacity var(--tab-out-duration, 0ms) var(--tab-out-ease, linear); + animation: var(--tab-enter, none) var(--tab-in-duration, 0ms) var(--tab-in-ease, linear) both; + } + + /* the panel keeps its box and clips whatever its body slides past. `clip` and + not `hidden`, so the vertical axis stays visible and the sticky diff headers + inside still resolve against the document */ + [data-tab-motion] > [data-tab-panel] { + overflow-x: clip; + } +} + @utility popover-transition { opacity: 0; scale: 0.98; + /* a transition reads off the state it lands on, so this one is the exit. the + two discrete properties are what keep a closing panel painted long enough + to run it, and firefox has neither — it leans on the popoverExit + attachment instead, which every panel using this utility needs */ transition: - opacity 120ms ease-out, - scale 120ms ease-out, - overlay 120ms allow-discrete, - display 120ms allow-discrete; + opacity 100ms ease-in, + scale 100ms ease-in, + overlay 100ms allow-discrete, + display 100ms allow-discrete; &:popover-open { opacity: 1; scale: 1; + /* and this one is the entry */ + transition: + opacity 75ms ease-out, + scale 75ms ease-out; } @starting-style { diff --git a/web/src/lib/components/profile/ProfileTabs.svelte b/web/src/lib/components/profile/ProfileTabs.svelte index 1b9b7b61..db1c792a 100644 --- a/web/src/lib/components/profile/ProfileTabs.svelte +++ b/web/src/lib/components/profile/ProfileTabs.svelte @@ -1,10 +1,6 @@ diff --git a/web/src/lib/components/profile/tabs/defs.ts b/web/src/lib/components/profile/tabs/defs.ts new file mode 100644 index 00000000..febbb4d6 --- /dev/null +++ b/web/src/lib/components/profile/tabs/defs.ts @@ -0,0 +1,34 @@ +// the profile tab bar, shared by the nav that draws it and the layout's content +// transition — both have to agree on the ids and on their left-to-right order, +// which is what decides the direction the content slides + +import SquareChartGantt from "$icon/square-chart-gantt"; +import BookMarked from "$icon/book-marked"; +import Star from "$icon/star"; +import LineSquiggle from "$icon/line-squiggle"; +import Shield from "$icon/shield"; +import type { TabDef } from "$lib/components/ui/Tabs.svelte"; +import type { ProfileCounts } from "../types"; + +export const profileTabs = (handle: string, counts: ProfileCounts): TabDef[] => { + const href = (id: string) => (id === "overview" ? `/${handle}` : `/${handle}?tab=${id}`); + return [ + { id: "overview", label: "Overview", icon: SquareChartGantt, href: href("overview") }, + { + id: "repos", + label: "Repositories", + icon: BookMarked, + count: counts.repos, + href: href("repos") + }, + { id: "starred", label: "Starred", icon: Star, count: counts.stars, href: href("starred") }, + { + id: "strings", + label: "Strings", + icon: LineSquiggle, + count: counts.strings, + href: href("strings") + }, + { id: "vouches", label: "Vouches", icon: Shield, href: href("vouches") } + ]; +}; diff --git a/web/src/lib/components/repo/RepoTabs.svelte b/web/src/lib/components/repo/RepoTabs.svelte index b7826854..b6254c63 100644 --- a/web/src/lib/components/repo/RepoTabs.svelte +++ b/web/src/lib/components/repo/RepoTabs.svelte @@ -1,10 +1,6 @@ diff --git a/web/src/lib/components/repo/tabs.ts b/web/src/lib/components/repo/tabs.ts new file mode 100644 index 00000000..44fccfc5 --- /dev/null +++ b/web/src/lib/components/repo/tabs.ts @@ -0,0 +1,36 @@ +// the repository tab bar, shared by the nav that draws it and the layout's +// content transition — both have to agree on the ids and on their left-to-right +// order, which is what decides the direction the content slides + +import SquareChartGantt from "$icon/square-chart-gantt"; +import CircleDot from "$icon/circle-dot"; +import GitPullRequest from "$icon/git-pull-request"; +import Layers2 from "$icon/layers-2"; +import Settings from "$icon/settings"; +import type { TabDef } from "$lib/components/ui/Tabs.svelte"; +import type { RepoCounts, RepoInfo } from "./types"; + +export const repoTabs = (repo: RepoInfo, counts: RepoCounts): TabDef[] => { + const base = `/${repo.ownerHandle}/${repo.name}`; + return [ + { id: "overview", label: "Overview", icon: SquareChartGantt, href: base }, + { + id: "issues", + label: "Issues", + icon: CircleDot, + count: counts.issues, + href: `${base}/issues` + }, + { + id: "pulls", + label: "Pulls", + icon: GitPullRequest, + count: counts.pulls, + href: `${base}/pulls` + }, + { id: "pipelines", label: "Pipelines", icon: Layers2, href: `${base}/pipelines` }, + // todo: only show this to collaborators — needs the viewer's push access on + // RepoInfo, which the layout load doesn't resolve yet + { id: "settings", label: "Settings", icon: Settings, href: `${base}/settings` } + ]; +}; diff --git a/web/src/lib/components/settings/SettingsPanel.svelte b/web/src/lib/components/settings/SettingsPanel.svelte index 217fe963..cde6e50d 100644 --- a/web/src/lib/components/settings/SettingsPanel.svelte +++ b/web/src/lib/components/settings/SettingsPanel.svelte @@ -15,8 +15,11 @@ +
diff --git a/web/src/lib/components/ui/TabContent.svelte b/web/src/lib/components/ui/TabContent.svelte new file mode 100644 index 00000000..2d78a8df --- /dev/null +++ b/web/src/lib/components/ui/TabContent.svelte @@ -0,0 +1,114 @@ + + + + +
+ {@render children()} +
diff --git a/web/src/lib/components/ui/TabPanel.svelte b/web/src/lib/components/ui/TabPanel.svelte index c8d3ad0d..5396a281 100644 --- a/web/src/lib/components/ui/TabPanel.svelte +++ b/web/src/lib/components/ui/TabPanel.svelte @@ -2,12 +2,20 @@ import { tv } from "tailwind-variants"; export const tabPanel = tv({ - base: "rounded-sm border border-border-default bg-background-default text-foreground-default", + slots: { + // the card itself: chrome only, so its box can hold still and stay welded to + // the tab bar above it while a transition moves what's inside + surface: + "rounded-sm border border-border-default bg-background-default text-foreground-default", + // everything the caller lays out lives in here, which is also what a tab + // transition travels — see the [data-tab-motion] rules in app.css + body: "" + }, variants: { // the common surface padding; turn off to lay out children yourself padded: { - true: "px-6 py-4", - false: "" + true: { body: "px-6 py-4" }, + false: {} } }, defaultVariants: { @@ -28,8 +36,12 @@ } let { as = "section", padded = true, class: className, children }: Props = $props(); + + const style = $derived(tabPanel({ padded })); - - {@render children()} + +
+ {@render children()} +
diff --git a/web/src/lib/components/ui/Tabs.svelte b/web/src/lib/components/ui/Tabs.svelte index f1858e7e..7de792ab 100644 --- a/web/src/lib/components/ui/Tabs.svelte +++ b/web/src/lib/components/ui/Tabs.svelte @@ -1,4 +1,6 @@ + - {@render children()} + + {@render children()} + diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index a5b82740..36256f6a 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -2,6 +2,7 @@ import { page } from "$app/state"; import type { SettingsNavItem } from "$lib/components/settings/SettingsNav.svelte"; import SettingsPanel from "$lib/components/settings/SettingsPanel.svelte"; + import TabContent from "$lib/components/ui/TabContent.svelte"; import User from "$icon/user-round"; import Key from "$icon/key"; import Mail from "$icon/mail"; @@ -16,7 +17,12 @@ { id: "profile", label: "Profile", href: "/settings", icon: User }, { id: "keys", label: "Keys", href: "/settings/keys", icon: Key }, { id: "emails", label: "Emails", href: "/settings/emails", icon: Mail }, - { id: "notifications", label: "Notifications", href: "/settings/notifications", icon: Bell }, + { + id: "notifications", + label: "Notifications", + href: "/settings/notifications", + icon: Bell + }, { id: "knots", label: "Knots", href: "/settings/knots", icon: Volleyball }, { id: "spindles", label: "Spindles", href: "/settings/spindles", icon: Spool }, { id: "sites", label: "Sites", href: "/settings/sites", icon: Globe } @@ -34,7 +40,16 @@
- {@render children()} + + + {@render children()} +
-- 2.51.2