From 974b246701dafe9048135a2611e870efa7b9ca70 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Mon, 8 Sep 2025 15:46:11 +0200 Subject: [PATCH 1/5] Restyle cr/revision preview toolbar --- bun.lock | 11 + packages/gitbook/package.json | 1 + .../components/AdminToolbar/AdminToolbar.tsx | 141 +-------- .../AdminToolbar/AdminToolbarClient.tsx | 176 +++++++++++ .../AdminToolbar/AnimatedLogo.module.css | 179 ++++++++++++ .../components/AdminToolbar/AnimatedLogo.tsx | 62 ++++ .../RefreshChangeRequestButton.tsx | 39 +-- .../src/components/AdminToolbar/Toolbar.tsx | 275 +++++++++++++++--- .../src/components/AdminToolbar/index.ts | 5 + .../components/AdminToolbar/transitions.ts | 73 +++++ .../AdminToolbar/useMagnificationEffect.ts | 216 ++++++++++++++ .../components/primitives/DateRelative.tsx | 14 +- 12 files changed, 993 insertions(+), 199 deletions(-) create mode 100644 packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx create mode 100644 packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css create mode 100644 packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx create mode 100644 packages/gitbook/src/components/AdminToolbar/transitions.ts create mode 100644 packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts diff --git a/bun.lock b/bun.lock index 2c54a5d042..df480de24b 100644 --- a/bun.lock +++ b/bun.lock @@ -140,6 +140,7 @@ "memoizee": "^0.4.17", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.12", "next": "15.3.5", "next-themes": "^0.2.1", "nuqs": "^2.2.3", @@ -2525,6 +2526,12 @@ "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], + "motion": ["motion@12.23.12", "", { "dependencies": { "framer-motion": "^12.23.12", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng=="], + + "motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="], + + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -4557,6 +4564,10 @@ "minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="], + "motion/framer-motion": ["framer-motion@12.23.12", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg=="], + + "motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "normalize-package-data/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 2f05c769fb..3f682f79a3 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -46,6 +46,7 @@ "memoizee": "^0.4.17", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.12", "next": "15.3.5", "next-themes": "^0.2.1", "nuqs": "^2.2.3", diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index 5faac2a685..f26204741a 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -1,143 +1,30 @@ import type { GitBookSiteContext } from '@/lib/context'; -import { Icon } from '@gitbook/icons'; -import React from 'react'; +import { AdminToolbarClient } from './AdminToolbarClient'; -import { tcls } from '@/lib/tailwind'; -import { DateRelative } from '../primitives'; -import { IframeWrapper } from './IframeWrapper'; -import { RefreshChangeRequestButton } from './RefreshChangeRequestButton'; -import { Toolbar, ToolbarBody, ToolbarButton, ToolbarButtonGroups } from './Toolbar'; - -interface AdminToolbarProps { +export interface AdminToolbarProps { context: GitBookSiteContext; } -function ToolbarLayout(props: { children: React.ReactNode }) { - return ( -
; - 'shadow-lg', - 'min-h-10', - 'min-w-40', - 'p-2', - 'max-w-md', - 'border-tint-12/1', - 'backdrop-blur-md' - )} - > - {props.children} -
- ); +export interface AdminToolbarClientProps { + context: SerializableGitBookSiteContext; } /** - * Toolbar with information for the content admin when previewing a revision or change-request. + * Server component that determines what type of toolbar to show and passes data to client component */ export async function AdminToolbar(props: AdminToolbarProps) { const { context } = props; - if (context.changeRequest) { - return ( - - - - ); - } - - if (context.revisionId !== context.space.revision) { - return ( - - - - ); - } - - return null; -} - -async function ChangeRequestToolbar(props: { context: GitBookSiteContext }) { - const { context } = props; - const { space, changeRequest } = context; + if (context.changeRequest || context.revisionId !== context.space.revision) { + // Create a serializable version of the context by removing function-containing objects + const { linker, imageResizer, dataFetcher, ...serializableContext } = context; - if (!changeRequest) { - return null; + return ; } - - return ( - - - - - - -

- #{changeRequest.number}: {changeRequest.subject ?? 'No subject'} -

-

- Change request updated -

-
- - - - - - -
-
- ); -} - -async function RevisionToolbar(props: { context: GitBookSiteContext }) { - const { context } = props; - const { revision } = context; - - return ( - - - - - - -

- Revision created -

- {revision.git ? ( -

- {revision.git.message} -

- ) : null} -
- - - - - {revision.git?.url ? ( - - - - ) : null} - -
-
- ); } diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx new file mode 100644 index 0000000000..55231c7dcc --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -0,0 +1,176 @@ +'use client'; +import { useReducedMotion } from 'framer-motion'; +import * as motion from 'motion/react-client'; +import { DateRelative } from '../primitives'; +import type { AdminToolbarClientProps } from './AdminToolbar'; +import { IframeWrapper } from './IframeWrapper'; +import { RefreshChangeRequestButton } from './RefreshChangeRequestButton'; +import { + Toolbar, + ToolbarBody, + ToolbarButton, + ToolbarButtonGroup, + ToolbarSeparator, +} from './Toolbar'; +import { getCopyVariants } from './transitions'; + +export function AdminToolbarClient(props: AdminToolbarClientProps) { + const { context } = props; + + if (context.changeRequest) { + return ( + + + + ); + } + + if (context.revisionId !== context.space.revision) { + return ( + + + + ); + } + + return null; +} + +function ChangeRequestToolbar(props: AdminToolbarClientProps) { + const { context } = props; + const { space, changeRequest, site } = context; + const reduceMotion = Boolean(useReducedMotion()); + + if (!changeRequest) { + return null; + } + + const crLabel = changeRequest.subject || 'Untitled change request'; + const author = changeRequest.createdBy.displayName; + + return ( + + +
+ + #{changeRequest.number} + + + {crLabel} + +
+ + by {author} + +
+ + + + + {/* Refresh to retrieve latest changes */} + + {/* Comment in app */} + + + {/* Open production site */} + + + {/* Open CR in GitBook */} + + +
+ ); +} + +function RevisionToolbar(props: AdminToolbarClientProps) { + const { context } = props; + const { revision, site } = context; + const reduceMotion = Boolean(useReducedMotion()); + + if (!revision) { + return null; + } + + const gitURL = revision.git?.url; + + return ( + + +
+ + Site revision + + + {context.site.title} + +
+ + Created + +
+ + {/* Open commit in Git client */} + + + {/* Open production site */} + + + {/* Open revision in GitBook */} + + +
+ ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css new file mode 100644 index 0000000000..5cf43998c5 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -0,0 +1,179 @@ +.svgLogo { + --logo-fill: var(--color-neutral-100); + --seg-A-color: #2782c4; + --seg-B-color: #43b7f2; + --seg-C-color: #8be2ff; + + --T: 2s; + shape-rendering: geometricPrecision; + vector-effect: non-scaling-stroke; +} + +:global(html.dark) .svgLogo { + --logo-fill: var(--color-neutral-800); + --seg-A-color: #19537d; + --seg-B-color: #2782c4; + --seg-C-color: #43b7f2; +} + +/* Base segment animation */ +.seg { + opacity: 0; + animation: segFade var(--T) ease both 0.06s, segAMove var(--T) linear, + segALen var(--T) linear; + animation-delay: 0.06s; + animation-fill-mode: forwards; +} + +.segA { + animation-name: segFade, segAMove, segALen; +} + +.segB { + animation-name: segFade, segBMove, segBLen; +} + +.segC { + animation-name: segFade, segCMove, segCLen; +} + +.trace { + animation: traceFill var(--T) ease both 0.06s; + animation-fill-mode: forwards; +} + +@keyframes traceFill { + 0% { + fill: transparent; + stroke:#46474c; + } + 95% { + fill: transparent; + stroke:#46474c; + } + 100% { + fill: var(--logo-fill); + stroke:none; + } +} + + +/* Segment A animation */ +@keyframes segAMove { + 0% { + stroke-dashoffset: 0; + stroke: var(--seg-A-color); + } + 50% { + stroke-dashoffset: -0.5; + stroke: var(--seg-A-color); + } + 95% { + stroke-dashoffset: -1; + } + 100% { + stroke-dashoffset: 0; + stroke: none; + } +} + +@keyframes segALen { + 0% { + stroke-dasharray: 0.18 0.82; + } + 95% { + stroke-dasharray: 0.22 0.78; + } + 100% { + stroke-dasharray: 1; + } +} + +/* Segment B animation */ +@keyframes segBMove { + 0% { + stroke-dashoffset: -0.18; + stroke: var(--seg-B-color); + } + 50% { + stroke-dashoffset: -0.72; + stroke: var(--seg-B-color); + } + 95% { + stroke-dashoffset: -1.18; + stroke: var(--seg-B-color); + } + 100% { + stroke-dashoffset: 0; + stroke: none; + } +} + +@keyframes segBLen { + 0%, + 50%, + 95% { + stroke-dasharray: 0.2 0.8; + } + 100% { + stroke-dasharray: 1; + } +} + +/* Segment C animation */ +@keyframes segCMove { + 0% { + stroke-dashoffset: -0.38; + stroke: var(--seg-C-color); + } + 50% { + stroke-dashoffset: -0.92; + stroke: var(--seg-C-color); + } + 95% { + stroke-dashoffset: -1.38; + stroke: var(--seg-C-color); + } + 100% { + stroke-dashoffset: 0; + stroke: none; + } +} + +@keyframes segCLen { + 0% { + stroke-dasharray: 0.22 0.78; + } + 95% { + stroke-dasharray: 0.18 0.82; + } + 100% { + stroke-dasharray: 1; + } +} + +/* Segment fade animation - stays visible at end */ +@keyframes segFade { + 0% { + opacity: 0; + } + 10% { + opacity: 1; + } + 100% { + opacity: 1; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .seg { + animation: none; + opacity: 0; + } + .trace { + animation: none; + fill: var(--logo-fill); + stroke:none; + } +} \ No newline at end of file diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx new file mode 100644 index 0000000000..f7a7c2790a --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.tsx @@ -0,0 +1,62 @@ +import { tcls } from '@/lib/tailwind'; +import type React from 'react'; +import styles from './AnimatedLogo.module.css'; + +export const AnimatedLogo: React.FC = () => { + return ( + + ); +}; + +const dPath = + 'M-5.07306 1.64898C-1.92626 3.46518 -0.352865 4.37328 1.37504 4.37477C3.10303 4.37628 4.67794 3.47098 7.82794 1.66027C7.82794 1.66027 27.9071 -9.88183 27.9071 -9.88183C28.8136 -10.4029 29.3724 -11.3687 29.3724 -12.4143C29.3724 -13.4598 28.8136 -14.4257 27.9071 -14.9467C27.9071 -14.9467 7.82063 -26.4931 7.82063 -26.4931C4.67414 -28.3018 3.10083 -29.2062 1.37453 -29.2055C-0.351665 -29.2048 -1.92427 -28.2992 -5.06947 -26.488C-5.06947 -26.488 -22.3372 -16.5441 -22.3372 -16.5441C-22.4651 -16.4704 -22.5291 -16.4336 -22.5888 -16.3986C-28.4872 -12.9457 -32.1315 -6.64173 -32.1802 0.192975C-32.1807 0.262075 -32.1807 0.335975 -32.1807 0.483575C-32.1807 0.631075 -32.1807 0.704875 -32.1802 0.773875C-32.1316 7.60087 -28.4955 13.899 -22.6075 17.3547C-22.548 17.3897 -22.4841 17.4266 -22.3564 17.5003C-22.3564 17.5003 -11.5399 23.7454 -11.5399 23.7454C-5.23716 27.3844 -2.08587 29.2039 1.37484 29.2051C4.83554 29.2062 7.98813 27.3889 14.2932 23.7541C14.2932 23.7541 25.7115 17.1718 25.7115 17.1718C28.8686 15.3518 30.4471 14.4418 31.3139 12.9416C32.1807 11.4414 32.1807 9.61938 32.1807 5.97517C32.1807 5.97517 32.1807 -1.06463 32.1807 -1.06463C32.1807 -2.07553 31.6332 -3.00723 30.75 -3.49913C29.8953 -3.97513 28.8536 -3.96802 28.0054 -3.48053C28.0054 -3.48053 4.59224 9.97808 4.59224 9.97808C3.02144 10.8811 2.23593 11.3326 1.37404 11.3329C0.512135 11.3331 -0.273565 10.8821 -1.84506 9.98007C-1.84506 9.98007 -17.6916 0.883775 -17.6916 0.883775C-18.4854 0.428075 -18.8823 0.200275 -19.2011 0.159175C-19.9279 0.065375 -20.6267 0.472475 -20.9036 1.15108C-21.025 1.44858 -21.0225 1.90628 -21.0176 2.82148C-21.014 3.49528 -21.0122 3.83218 -20.9492 4.14208C-20.8082 4.83607 -20.4431 5.46448 -19.91 5.93068C-19.672 6.13888 -19.3802 6.30727 -18.7966 6.64407C-18.7966 6.64407 -1.85397 16.4227 -1.85397 16.4227C-0.278465 17.332 0.509335 17.7867 1.37434 17.7869C2.23934 17.7872 3.02734 17.3329 4.60333 16.4245C4.60333 16.4245 25.3699 4.45377 25.3699 4.45377C25.9083 4.14348 26.1774 3.98828 26.3792 4.10488C26.5811 4.22148 26.5811 4.53218 26.5811 5.15357C26.5811 5.15357 26.5811 8.34667 26.5811 8.34667C26.5811 9.25768 26.5811 9.71317 26.3643 10.0883C26.1476 10.4634 25.753 10.6908 24.9637 11.1458C24.9637 11.1458 7.83524 21.0193 7.83524 21.0193C4.68194 22.837 3.10533 23.7458 1.37463 23.7451C-0.356065 23.7443 -1.93186 22.834 -5.08347 21.0134C-5.08347 21.0134 -21.1086 11.7563 -21.1086 11.7563C-21.1595 11.7269 -21.1849 11.7122 -21.2087 11.6984C-24.5687 9.73487 -26.642 6.14287 -26.6614 2.25128C-26.6616 2.22378 -26.6616 2.19438 -26.6616 2.13567C-26.6616 2.13567 -26.6616 -0.795425 -26.6616 -0.795425C-26.6616 -2.94372 -25.5174 -4.92953 -23.6587 -6.00682C-22.0163 -6.95883 -19.9905 -6.96072 -18.3463 -6.01183C-18.3463 -6.01183 -5.07306 1.64898 -5.07306 1.64898Z'; diff --git a/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx b/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx index d141140a60..68274351b7 100644 --- a/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx +++ b/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx @@ -1,27 +1,28 @@ 'use client'; -import { Icon } from '@gitbook/icons'; import React from 'react'; import { useCheckForContentUpdate } from '@/components/AutoRefreshContent'; import { tcls } from '@/lib/tailwind'; -import { ToolbarButton } from './Toolbar'; +import { ToolbarButton, type ToolbarButtonProps } from './Toolbar'; // We don't show the button if the content has been updated 30s ago or less. -const minInterval = 1000 * 30; // 5 minutes +const minInterval = 1000 * 30; /** * Button to refresh the page if the content has been updated. */ export function RefreshChangeRequestButton(props: { + className?: string; spaceId: string; changeRequestId: string; revisionId: string; updatedAt: number; + motionValues?: ToolbarButtonProps['motionValues']; }) { - const { updatedAt } = props; + const { updatedAt, className, motionValues } = props; - const [visible, setVisible] = React.useState(false); + const [coolingDown, setCoolingDown] = React.useState(false); const [loading, setLoading] = React.useState(false); const checkForUpdates = useCheckForContentUpdate(props); @@ -31,40 +32,42 @@ export function RefreshChangeRequestButton(props: { await checkForUpdates(); } finally { setLoading(false); - setVisible(false); + setCoolingDown(true); } }, [checkForUpdates]); // Show the button if the content has been updated more than 30s ago. React.useEffect(() => { if (updatedAt < Date.now() - minInterval) { - setVisible(true); + setCoolingDown(false); } }, [updatedAt]); // 30sec after being hidden, we show the button again React.useEffect(() => { - if (!visible) { + if (!coolingDown) { const timeout = setTimeout(() => { - setVisible(true); + setCoolingDown(false); }, minInterval); return () => clearTimeout(timeout); } - }, [visible]); - - if (!visible) { - return null; - } + }, [coolingDown]); return ( { + if (coolingDown) { + return; + } event.preventDefault(); refresh(); }} - > - - + className={tcls(className, 'overflow-visible')} + disabled={loading || coolingDown} + motionValues={motionValues} + icon="rotate" + iconClassName={tcls(loading ? 'animate-spin' : null)} + /> ); } diff --git a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx index 4298d8db6c..56b7a5525f 100644 --- a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx @@ -1,65 +1,248 @@ 'use client'; - -import type * as React from 'react'; +import { AnimatePresence, type MotionValue, motion } from 'motion/react'; +import React from 'react'; +import { AnimatedLogo } from './AnimatedLogo'; import { tcls } from '@/lib/tailwind'; +import { Icon, type IconName } from '@gitbook/icons'; +import { useReducedMotion } from 'framer-motion'; +import { useTheme } from 'next-themes'; +import { Tooltip } from '../primitives'; +import { minifyButtonAnimation, toolbarEasings } from './transitions'; +import { useMagnificationEffect } from './useMagnificationEffect'; + +const DURATION_LOGO_APPEARANCE = 2000; +const DELAY_BETWEEN_LOGO_AND_CONTENT = 100; export function Toolbar(props: { children: React.ReactNode }) { - const { children } = props; + const [minified, setMinified] = React.useState(true); + const [showToolbarControls, setShowToolbarControls] = React.useState(false); + const [isReady, setIsReady] = React.useState(false); + const reduceMotion = Boolean(useReducedMotion()); + const { theme } = useTheme(); + + // Wait for page to be ready, then show the toolbar + React.useEffect(() => { + const handleLoad = () => { + // Small delay to ensure everything is settled + setTimeout(() => { + setIsReady(true); + }, 100); + }; + + if (document.readyState === 'complete') { + handleLoad(); + } else { + window.addEventListener('load', handleLoad); + return () => window.removeEventListener('load', handleLoad); + } + }, []); + + // After toolbar appears, wait then show the full content + React.useEffect(() => { + if (isReady) { + setTimeout(() => { + setMinified(false); + }, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT); + } + }, [isReady]); + + // Don't render anything until page is ready + if (!isReady) { + return null; + } return ( -
setShowToolbarControls(true)} + onMouseLeave={() => setShowToolbarControls(false)} + className="-translate-x-1/2 fixed bottom-5 left-1/2 z-40 w-auto max-w-xl transform px-4" > - {children} -
+ + { + if (minified) { + setMinified((prev) => !prev); + } + }} + layout={!reduceMotion} + transition={toolbarEasings.spring} + className={tcls( + minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5', + 'flex', + 'items-center', + 'justify-center', + 'min-h-11', + 'min-w-12', + 'h-12', + 'py-2', + 'border-tint-1/3', + 'backdrop-blur-sm', + 'origin-center' + )} + initial={{ + scale: reduceMotion ? 1 : 0.5, + opacity: reduceMotion ? 1 : 0.5, + }} + animate={{ + scale: 1, + opacity: 1, + boxShadow: minified + ? '0 4px 40px 8px rgba(0, 0, 0, .2), 0 0 0 .5px rgba(0, 0, 0, .4), inset 0 .5px 0 0 hsla(0, 0%, 100%, .15)' + : '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)', + }} + style={{ + background: + theme === 'dark' + ? 'linear-gradient(110deg, rgba(256, 256, 256, 0.90) 0%, rgba(256, 256, 256, 0.80) 100%)' + : 'linear-gradient(110deg, rgba(20, 23, 28, 0.90) 0%, rgba(20, 23, 28, 0.80) 100%)', + borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions + }} + > + {/* Logo with stroke segments animation in blue-tints */} + + + + + {!minified ? props.children : null} + + {!minified && showToolbarControls && ( + + )} + + + ); } export function ToolbarBody(props: { children: React.ReactNode }) { - return
{props.children}
; + return
{props.children}
; } -export function ToolbarButtonGroups(props: { children: React.ReactNode }) { - return
{props.children}
; -} +export function ToolbarButtonGroup(props: { children: React.ReactNode }) { + const containerRef = React.useRef(null); + const { buttonMotionValues } = useMagnificationEffect(containerRef); + const reduceMotion = useReducedMotion(); -export function ToolbarButton(props: React.HTMLProps) { - const { children, ...rest } = props; return ( - - {children} - + {React.Children.map(props.children, (child, index) => { + const motionValues = buttonMotionValues[index]; + return React.cloneElement(child as React.ReactElement, { + motionValues, + key: `toolbar-button-${index}`, + }); + })} + + ); +} + +export interface ToolbarButtonProps extends React.HTMLProps { + motionValues?: { + scale: MotionValue; + x: MotionValue; + }; + icon?: IconName; + iconClassName?: string; +} + +export function ToolbarButton(props: ToolbarButtonProps) { + const { title, disabled, motionValues, className, style, href, onClick, icon, iconClassName } = + props; + const reduceMotion = useReducedMotion(); + + return ( + + + + {icon && } + + + + ); +} + +export function ToolbarSeparator() { + return
; +} + +function MinifyButton(props: { setMinified: (minified: boolean) => void; reduceMotion: boolean }) { + return ( + + { + e.stopPropagation(); + props.setMinified(true); + }} + className={tcls( + '-top-2 -right-4 absolute flex size-4 cursor-pointer items-center justify-center rounded-full border', + 'border-neutral-500 bg-neutral-700 hover:border-neutral-400 hover:bg-neutral-600', + 'dark:border-neutral-400 dark:bg-neutral-200 dark:hover:border-neutral-200 dark:hover:bg-neutral-100' + )} + > + + + ); } diff --git a/packages/gitbook/src/components/AdminToolbar/index.ts b/packages/gitbook/src/components/AdminToolbar/index.ts index dde5147502..1093bbbc0c 100644 --- a/packages/gitbook/src/components/AdminToolbar/index.ts +++ b/packages/gitbook/src/components/AdminToolbar/index.ts @@ -1,2 +1,7 @@ export * from './AdminToolbar'; export * from './IframeWrapper'; +export { AdminToolbar } from './AdminToolbar'; + +export * from './AdminToolbarClient'; +export * from './Toolbar'; +export * from './transitions'; diff --git a/packages/gitbook/src/components/AdminToolbar/transitions.ts b/packages/gitbook/src/components/AdminToolbar/transitions.ts new file mode 100644 index 0000000000..bfd0bcb34d --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/transitions.ts @@ -0,0 +1,73 @@ +import type { Transition } from 'motion/react'; + +// Slight bounce but minimal overshoot esp. at the minimized state. +const spring = { + type: 'spring' as const, + stiffness: 130, + damping: 19, + mass: 1, +}; + +const parent = { + hidden: { opacity: 0 }, + show: { + opacity: 1, + transition: { + delayChildren: 0.4, + staggerChildren: 0.1, + }, + }, +}; + +const staggeringChild = { + hidden: { + opacity: 0, + scale: 0.7, + }, + show: { + opacity: 1, + scale: 1, + transition: { + duration: 0.3, + type: 'spring', + } as Transition, + }, +}; + +export const minifyButtonAnimation = { + initial: { + scale: 0.5, + opacity: 0, + }, + animate: { + scale: 1, + opacity: 1, + }, + exit: { + scale: 0.5, + opacity: 0, + }, +}; + +export const getCopyVariants = (position: number) => { + return { + initial: { + opacity: 0, + x: -10, + }, + animate: { + opacity: 1, + x: 0, + }, + transition: { + duration: 0.1, + delay: position / 10 + 0.3, + } as Transition, + }; +}; + +export const toolbarEasings = { + spring, + parent, + staggeringChild, +}; diff --git a/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts new file mode 100644 index 0000000000..89355c0cc0 --- /dev/null +++ b/packages/gitbook/src/components/AdminToolbar/useMagnificationEffect.ts @@ -0,0 +1,216 @@ +import { type MotionValue, motionValue } from 'framer-motion'; +import React from 'react'; + +interface ButtonMotionValues { + scale: MotionValue; + x: MotionValue; +} + +interface MagnificationConfig { + /** Size of each button in pixels - used for spacing calculations */ + buttonSize?: number; + /** Maximum scale factor for buttons when mouse is directly over them */ + maxScale?: number; + /** Distance in pixels from mouse where buttons start scaling */ + influenceRadius?: number; + /** Multiplier for spacing between buttons - higher values create more space */ + spacingMultiplier?: number; + /** Controls scaling curve - higher values make scaling more dramatic near mouse */ + scaleExponent?: number; + /** Padding around container for mouse detection in pixels */ + padding?: number; +} + +const defaultConfig: Required = { + buttonSize: 32, // Standard button size for 32px (size-8) buttons + maxScale: 1.3, // 30% scale increase - noticeable but not overwhelming + influenceRadius: 80, // ~2.5 button widths of influence + spacingMultiplier: 1.3, // Creates proportional spacing to scale increase + scaleExponent: 2.5, // Exponential curve for dramatic close-range scaling + padding: 10, // Small buffer zone around container edges +}; + +// Helper functions for cleaner code +const createMotionValues = (count: number): ButtonMotionValues[] => + Array.from({ length: count }, () => ({ + scale: motionValue(1), + x: motionValue(0), + })); + +const resetMotionValues = (motionValues: ButtonMotionValues[]) => { + motionValues.forEach(({ scale, x }) => { + scale.set(1); + x.set(0); + }); +}; + +const captureButtonPositions = (buttons: HTMLElement[]) => { + // Reset transforms to get original positions + buttons.forEach((button) => (button.style.transform = '')); + return buttons.map((button) => { + const rect = button.getBoundingClientRect(); + return { left: rect.left, width: rect.width }; + }); +}; + +const calculateScale = ( + mouseX: number, + buttonCenterX: number, + containerRect: DOMRect, + config: Required +) => { + const distance = Math.abs(mouseX - buttonCenterX); + + if (distance > config.influenceRadius) return 1; + + // Edge influence to reduce scaling near container edges + const distanceFromEdge = Math.min(mouseX - containerRect.left, containerRect.right - mouseX); + const edgeInfluence = Math.min(distanceFromEdge / 20, 1); + + // Exponential scaling curve + const progress = distance / config.influenceRadius; + const exponentialProgress = (1 - progress) ** config.scaleExponent; + const baseScale = 1 + (config.maxScale - 1) * exponentialProgress; + + return 1 + (baseScale - 1) * edgeInfluence; +}; + +const calculateSpacing = ( + buttonIndex: number, + buttonEffects: Array<{ scale: number; translateX: number }>, + positions: Array<{ left: number; width: number }>, + config: Required +) => { + const currentPos = positions[buttonIndex]; + if (!currentPos) return 0; + + const buttonCenterX = currentPos.left + currentPos.width / 2; + let leftPush = 0; + let rightPush = 0; + + buttonEffects.forEach((otherEffect, otherIndex) => { + if (otherIndex === buttonIndex) return; + + const otherPos = positions[otherIndex]; + if (!otherPos) return; + + const otherCenterX = otherPos.left + otherPos.width / 2; + const distanceBetween = Math.abs(buttonCenterX - otherCenterX); + + // Only apply influence if close enough + if (distanceBetween < config.influenceRadius * 1.5) { + const influenceStrength = Math.max( + 0, + 1 - distanceBetween / (config.influenceRadius * 1.5) + ); + const extraSpace = (otherEffect.scale - 1) * config.buttonSize; + const pushAmount = (extraSpace / 2) * config.spacingMultiplier * influenceStrength; + + if (otherCenterX < buttonCenterX) { + rightPush += pushAmount; + } else { + leftPush += pushAmount; + } + } + }); + + return rightPush - leftPush; +}; + +export function useMagnificationEffect( + containerRef: React.RefObject, + config: MagnificationConfig = {} +) { + const [buttonMotionValues, setButtonMotionValues] = React.useState([]); + const originalPositionsRef = React.useRef>([]); + + const finalConfig = { ...defaultConfig, ...config }; + + React.useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const buttons = Array.from(container.querySelectorAll('#toolbar-button')) as HTMLElement[]; + + // Initialize motion values if button count changed + if (buttonMotionValues.length !== buttons.length) { + setButtonMotionValues(createMotionValues(buttons.length)); + } + + const handleMouseMove = (event: MouseEvent) => { + const buttons = Array.from( + container.querySelectorAll('#toolbar-button') + ) as HTMLElement[]; + + // Capture positions if needed + if (originalPositionsRef.current.length !== buttons.length) { + originalPositionsRef.current = captureButtonPositions(buttons); + } + + const containerRect = container.getBoundingClientRect(); + const { clientX: mouseX, clientY: mouseY } = event; + + // Check if mouse is in range + const isInRange = + mouseX >= containerRect.left - finalConfig.padding && + mouseX <= containerRect.right + finalConfig.padding && + mouseY >= containerRect.top - finalConfig.padding && + mouseY <= containerRect.bottom + finalConfig.padding; + + if (!isInRange) { + resetMotionValues(buttonMotionValues); + return; + } + + // Calculate scales for all buttons + const buttonEffects = buttons.map((_, index) => { + const pos = originalPositionsRef.current[index]; + if (!pos) return { scale: 1, translateX: 0 }; + + const buttonCenterX = pos.left + pos.width / 2; + const scale = calculateScale(mouseX, buttonCenterX, containerRect, finalConfig); + return { scale, translateX: 0 }; + }); + + // Calculate spacing for all buttons + buttonEffects.forEach((effect, index) => { + effect.translateX = calculateSpacing( + index, + buttonEffects, + originalPositionsRef.current, + finalConfig + ); + }); + + // Update motion values + buttonEffects.forEach((effect, index) => { + const motionValue = buttonMotionValues[index]; + if (motionValue) { + motionValue.scale.set(effect.scale); + motionValue.x.set(effect.translateX); + } + }); + }; + + const handleMouseLeave = () => resetMotionValues(buttonMotionValues); + + container.addEventListener('mousemove', handleMouseMove); + container.addEventListener('mouseleave', handleMouseLeave); + + return () => { + container.removeEventListener('mousemove', handleMouseMove); + container.removeEventListener('mouseleave', handleMouseLeave); + }; + }, [ + finalConfig.buttonSize, + finalConfig.maxScale, + finalConfig.influenceRadius, + finalConfig.spacingMultiplier, + finalConfig.scaleExponent, + finalConfig.padding, + containerRef, + buttonMotionValues, + ]); + + return { buttonMotionValues }; +} diff --git a/packages/gitbook/src/components/primitives/DateRelative.tsx b/packages/gitbook/src/components/primitives/DateRelative.tsx index 3d8fc23a1d..796dafac0c 100644 --- a/packages/gitbook/src/components/primitives/DateRelative.tsx +++ b/packages/gitbook/src/components/primitives/DateRelative.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useLanguage } from '@/intl/client'; +import { Tooltip } from './Tooltip'; /** * Display a date as a relative time. @@ -28,14 +29,11 @@ export function DateRelative(props: { value: string }) { const date = new Date(value); const diff = now - date.getTime(); return ( - + + + ); } From dbaaf3505cadd0791899d25b8efae9f159c1d851 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Fri, 12 Sep 2025 09:43:59 +0200 Subject: [PATCH 2/5] Restrict serialized fields --- .../components/AdminToolbar/AdminToolbar.tsx | 91 +++++++++++++++++-- 1 file changed, 82 insertions(+), 9 deletions(-) diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index f26204741a..1388e9929d 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -5,14 +5,49 @@ export interface AdminToolbarProps { context: GitBookSiteContext; } -// Serializable version of GitBookSiteContext (excludes functions) -export type SerializableGitBookSiteContext = Omit< - GitBookSiteContext, - 'linker' | 'imageResizer' | 'dataFetcher' ->; +// Minimal types containing only the fields needed for AdminToolbar to restrict what gets serialized +export type MinimalChangeRequest = { + id: string; + number: number; + subject: string | null; + revision: string; + updatedAt: string; + createdBy: { + displayName: string; + }; + urls: { + app: string; + }; +}; + +export type MinimalRevision = { + createdAt: string; + urls: { + app: string; + }; + git?: { + url: string | undefined; + } | null; +}; + +export type AdminToolbarContext = { + space: { + id: string; + revision: string; + }; + changeRequest: MinimalChangeRequest | null; + revision: MinimalRevision; + revisionId: string; + site: { + title: string; + urls: { + published: string | undefined; + }; + }; +}; export interface AdminToolbarClientProps { - context: SerializableGitBookSiteContext; + context: AdminToolbarContext; } /** @@ -22,9 +57,47 @@ export async function AdminToolbar(props: AdminToolbarProps) { const { context } = props; if (context.changeRequest || context.revisionId !== context.space.revision) { - // Create a serializable version of the context by removing function-containing objects - const { linker, imageResizer, dataFetcher, ...serializableContext } = context; + // Create a minimal context with only the fields needed for AdminToolbar + const minimalContext: AdminToolbarContext = { + space: { + id: context.space.id, + revision: context.space.revision, + }, + changeRequest: context.changeRequest + ? { + id: context.changeRequest.id, + number: context.changeRequest.number, + subject: context.changeRequest.subject, + revision: context.changeRequest.revision, + updatedAt: context.changeRequest.updatedAt, + createdBy: { + displayName: context.changeRequest.createdBy.displayName, + }, + urls: { + app: context.changeRequest.urls.app, + }, + } + : null, + revision: { + createdAt: context.revision.createdAt, + urls: { + app: context.revision.urls.app, + }, + git: context.revision.git + ? { + url: context.revision.git.url, + } + : null, + }, + revisionId: context.revisionId, + site: { + title: context.site.title, + urls: { + published: context.site.urls.published, + }, + }, + }; - return ; + return ; } } From f88f40c3a9a38d46dbe862a32d437769bc64eaba Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Fri, 12 Sep 2025 14:11:24 +0200 Subject: [PATCH 3/5] Improvements from feedback --- .../AdminToolbar/AdminToolbarClient.tsx | 148 +++++++++++------- .../AdminToolbar/AnimatedLogo.module.css | 5 +- .../RefreshChangeRequestButton.tsx | 2 +- .../src/components/AdminToolbar/Toolbar.tsx | 63 ++++---- .../src/components/AdminToolbar/index.ts | 4 +- .../AdminToolbar/useMagnificationEffect.ts | 4 +- packages/gitbook/tailwind.config.ts | 3 + 7 files changed, 133 insertions(+), 96 deletions(-) diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 55231c7dcc..955168bd85 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -1,5 +1,6 @@ 'use client'; -import { useReducedMotion } from 'framer-motion'; +import { Icon } from '@gitbook/icons'; +import { MotionConfig } from 'motion/react'; import * as motion from 'motion/react-client'; import { DateRelative } from '../primitives'; import type { AdminToolbarClientProps } from './AdminToolbar'; @@ -20,7 +21,9 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { if (context.changeRequest) { return ( - + + + ); } @@ -28,7 +31,9 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { if (context.revisionId !== context.space.revision) { return ( - + + + ); } @@ -39,38 +44,25 @@ export function AdminToolbarClient(props: AdminToolbarClientProps) { function ChangeRequestToolbar(props: AdminToolbarClientProps) { const { context } = props; const { space, changeRequest, site } = context; - const reduceMotion = Boolean(useReducedMotion()); if (!changeRequest) { return null; } - const crLabel = changeRequest.subject || 'Untitled change request'; + const crLabel = changeRequest.subject || 'Untitled'; const author = changeRequest.createdBy.displayName; return ( -
- - #{changeRequest.number} - - - {crLabel} - -
- - by {author} - + + + by {author} + + } + />
@@ -95,7 +87,7 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { {/* Open production site */} @@ -103,7 +95,7 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { {/* Open CR in GitBook */} @@ -115,7 +107,6 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { function RevisionToolbar(props: AdminToolbarClientProps) { const { context } = props; const { revision, site } = context; - const reduceMotion = Boolean(useReducedMotion()); if (!revision) { return null; @@ -126,47 +117,52 @@ function RevisionToolbar(props: AdminToolbarClientProps) { return ( -
- - Site revision - - - {context.site.title} - -
- - Created - + + + Created + + } + />
+ {/* Open commit in Git client */} + Setup GitSync to edit using Git{' '} + +
+ ) + } href={gitURL} disabled={!gitURL} icon={gitURL ? (gitURL.includes('github.com') ? 'github' : 'gitlab') : 'github'} /> - - {/* Open production site */} - - {/* Open revision in GitBook */} @@ -174,3 +170,45 @@ function RevisionToolbar(props: AdminToolbarClientProps) { ); } + +function ToolbarTitle(props: { prefix: string; suffix: string }) { + return ( +
+ + +
+ ); +} + +function ToolbarTitlePrefix(props: { title: string }) { + return ( + + {props.title} + + ); +} + +function ToolbarTitleSuffix(props: { title: string }) { + return ( + + {props.title} + + ); +} + +function ToolbarSubtitle(props: { subtitle: React.ReactNode }) { + return ( + + {props.subtitle} + + ); +} diff --git a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css index 5cf43998c5..52cb508c7e 100644 --- a/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css +++ b/packages/gitbook/src/components/AdminToolbar/AnimatedLogo.module.css @@ -14,6 +14,7 @@ --seg-A-color: #19537d; --seg-B-color: #2782c4; --seg-C-color: #43b7f2; + --trace-color: #46474c; } /* Base segment animation */ @@ -45,11 +46,11 @@ @keyframes traceFill { 0% { fill: transparent; - stroke:#46474c; + stroke:var(--trace-color); } 95% { fill: transparent; - stroke:#46474c; + stroke:var(--trace-color); } 100% { fill: var(--logo-fill); diff --git a/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx b/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx index 68274351b7..9939f19387 100644 --- a/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx +++ b/packages/gitbook/src/components/AdminToolbar/RefreshChangeRequestButton.tsx @@ -67,7 +67,7 @@ export function RefreshChangeRequestButton(props: { disabled={loading || coolingDown} motionValues={motionValues} icon="rotate" - iconClassName={tcls(loading ? 'animate-spin' : null)} + iconClassName={loading ? 'animate-spin' : undefined} /> ); } diff --git a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx index 56b7a5525f..7a180e8380 100644 --- a/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/Toolbar.tsx @@ -1,12 +1,10 @@ 'use client'; -import { AnimatePresence, type MotionValue, motion } from 'motion/react'; +import { AnimatePresence, type MotionValue, motion, useReducedMotion } from 'motion/react'; import React from 'react'; import { AnimatedLogo } from './AnimatedLogo'; import { tcls } from '@/lib/tailwind'; import { Icon, type IconName } from '@gitbook/icons'; -import { useReducedMotion } from 'framer-motion'; -import { useTheme } from 'next-themes'; import { Tooltip } from '../primitives'; import { minifyButtonAnimation, toolbarEasings } from './transitions'; import { useMagnificationEffect } from './useMagnificationEffect'; @@ -18,16 +16,11 @@ export function Toolbar(props: { children: React.ReactNode }) { const [minified, setMinified] = React.useState(true); const [showToolbarControls, setShowToolbarControls] = React.useState(false); const [isReady, setIsReady] = React.useState(false); - const reduceMotion = Boolean(useReducedMotion()); - const { theme } = useTheme(); // Wait for page to be ready, then show the toolbar React.useEffect(() => { const handleLoad = () => { - // Small delay to ensure everything is settled - setTimeout(() => { - setIsReady(true); - }, 100); + setIsReady(true); }; if (document.readyState === 'complete') { @@ -41,9 +34,11 @@ export function Toolbar(props: { children: React.ReactNode }) { // After toolbar appears, wait then show the full content React.useEffect(() => { if (isReady) { - setTimeout(() => { + const expandAfterTimeout = setTimeout(() => { setMinified(false); }, DURATION_LOGO_APPEARANCE + DELAY_BETWEEN_LOGO_AND_CONTENT); + + return () => clearTimeout(expandAfterTimeout); } }, [isReady]); @@ -65,7 +60,7 @@ export function Toolbar(props: { children: React.ReactNode }) { setMinified((prev) => !prev); } }} - layout={!reduceMotion} + layout transition={toolbarEasings.spring} className={tcls( minified ? 'cursor-pointer px-2' : 'pr-2 pl-3.5', @@ -78,11 +73,13 @@ export function Toolbar(props: { children: React.ReactNode }) { 'py-2', 'border-tint-1/3', 'backdrop-blur-sm', - 'origin-center' + 'origin-center', + 'bg-[linear-gradient(110deg,rgba(20,23,28,0.90)_0%,rgba(20,23,28,0.80)_100%)]', + 'dark:bg-[linear-gradient(110deg,rgba(256,256,256,0.90)_0%,rgba(256,256,256,0.80)_100%)]' )} initial={{ - scale: reduceMotion ? 1 : 0.5, - opacity: reduceMotion ? 1 : 0.5, + scale: 1, + opacity: 1, }} animate={{ scale: 1, @@ -92,23 +89,17 @@ export function Toolbar(props: { children: React.ReactNode }) { : '0 4px 40px 8px rgba(0, 0, 0, .4), 0 0 0 .5px rgba(0, 0, 0, .8), inset 0 .5px 0 0 hsla(0, 0%, 100%, .3)', }} style={{ - background: - theme === 'dark' - ? 'linear-gradient(110deg, rgba(256, 256, 256, 0.90) 0%, rgba(256, 256, 256, 0.80) 100%)' - : 'linear-gradient(110deg, rgba(20, 23, 28, 0.90) 0%, rgba(20, 23, 28, 0.80) 100%)', borderRadius: '100px', // This is set on `style` so Framer Motion can correct for distortions }} > {/* Logo with stroke segments animation in blue-tints */} - + {!minified ? props.children : null} - {!minified && showToolbarControls && ( - - )} + {!minified && showToolbarControls && } @@ -122,34 +113,40 @@ export function ToolbarBody(props: { children: React.ReactNode }) { export function ToolbarButtonGroup(props: { children: React.ReactNode }) { const containerRef = React.useRef(null); const { buttonMotionValues } = useMagnificationEffect(containerRef); - const reduceMotion = useReducedMotion(); return ( - {React.Children.map(props.children, (child, index) => { + {React.Children.toArray(props.children).map((child, index) => { const motionValues = buttonMotionValues[index]; - return React.cloneElement(child as React.ReactElement, { + const childEl = child as React.ReactElement; + const key = + childEl.key || + childEl.props.icon || + childEl.props.href || + `toolbar-button-${index}`; + return React.cloneElement(childEl, { motionValues, - key: `toolbar-button-${index}`, + key, }); })} ); } -export interface ToolbarButtonProps extends React.HTMLProps { +export interface ToolbarButtonProps extends Omit, 'title'> { motionValues?: { scale: MotionValue; x: MotionValue; }; icon?: IconName; iconClassName?: string; + title?: React.ReactNode; } export function ToolbarButton(props: ToolbarButtonProps) { @@ -158,12 +155,11 @@ export function ToolbarButton(props: ToolbarButtonProps) { const reduceMotion = useReducedMotion(); return ( - + ; + return
; } -function MinifyButton(props: { setMinified: (minified: boolean) => void; reduceMotion: boolean }) { +function MinifyButton(props: { setMinified: (minified: boolean) => void }) { return ( { const buttons = Array.from( - container.querySelectorAll('#toolbar-button') + container.querySelectorAll('.toolbar-button') ) as HTMLElement[]; // Capture positions if needed diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 5ad889049e..87d42fdeaf 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -81,6 +81,9 @@ const config: Config = { ], var: ['var(--font-family)'], }, + fontSize: { + xxs: ['0.625rem', { lineHeight: '0.75rem' }], + }, colors: { // Dynamic colors matching the customization settings From bf5fdd1e9c84332d5b0cb0162740ff4b16068077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Sun, 14 Sep 2025 21:03:24 +0200 Subject: [PATCH 4/5] bun install --- bun.lock | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index cc547167e1..a515522a67 100644 --- a/bun.lock +++ b/bun.lock @@ -64,7 +64,7 @@ }, "packages/expr": { "name": "@gitbook/expr", - "version": "1.1.0", + "version": "1.2.0", "dependencies": { "acorn": "^8.14.0", "acorn-loose": "8.4.0", @@ -95,7 +95,7 @@ }, "packages/gitbook": { "name": "gitbook", - "version": "0.17.1", + "version": "0.18.0", "dependencies": { "@gitbook/api": "catalog:", "@gitbook/browser-types": "workspace:*", @@ -142,6 +142,7 @@ "memoizee": "^0.4.17", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", + "motion": "^12.23.12", "next": "15.4.0", "next-themes": "^0.2.1", "nuqs": "^2.2.3", @@ -197,7 +198,7 @@ }, "packages/icons": { "name": "@gitbook/icons", - "version": "0.3.0", + "version": "0.3.1", "bin": { "gitbook-icons": "./bin/gitbook-icons.js", }, @@ -233,7 +234,7 @@ }, "packages/react-contentkit": { "name": "@gitbook/react-contentkit", - "version": "0.7.4", + "version": "0.7.5", "dependencies": { "@gitbook/api": "catalog:", "@gitbook/icons": "workspace:*", @@ -265,7 +266,7 @@ }, "packages/react-openapi": { "name": "@gitbook/react-openapi", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@gitbook/expr": "workspace:*", "@gitbook/openapi-parser": "workspace:*", @@ -2432,6 +2433,12 @@ "mnemonist": ["mnemonist@0.38.3", "", { "dependencies": { "obliterator": "^1.6.1" } }, "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw=="], + "motion": ["motion@12.23.12", "", { "dependencies": { "framer-motion": "^12.23.12", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng=="], + + "motion-dom": ["motion-dom@12.23.12", "", { "dependencies": { "motion-utils": "^12.23.6" } }, "sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw=="], + + "motion-utils": ["motion-utils@12.23.6", "", {}, "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -4352,6 +4359,10 @@ "minizlib/minipass": ["minipass@2.9.0", "", { "dependencies": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } }, "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg=="], + "motion/framer-motion": ["framer-motion@12.23.12", "", { "dependencies": { "motion-dom": "^12.23.12", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg=="], + + "motion/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], From fd045803e12bf90c37ff9b61755e4452c52ad3a2 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Mon, 15 Sep 2025 08:18:07 +0200 Subject: [PATCH 5/5] Remove keys --- .../src/components/AdminToolbar/AdminToolbarClient.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx index 955168bd85..dae1f788ae 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbarClient.tsx @@ -74,13 +74,11 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { changeRequestId={changeRequest.id} revisionId={changeRequest.revision} updatedAt={new Date(changeRequest.updatedAt).getTime()} - key="refresh-button" /> {/* Comment in app */} @@ -88,7 +86,6 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { @@ -96,7 +93,6 @@ function ChangeRequestToolbar(props: AdminToolbarClientProps) { @@ -158,7 +154,6 @@ function RevisionToolbar(props: AdminToolbarClientProps) {