From 01766e1877e23a61f0c0c30f6e97adfe1ff55d58 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Thu, 12 Jun 2025 17:54:47 +0200 Subject: [PATCH 1/5] [devtools] port next-logo --- .../devtools-indicator/devtools-indicator.tsx | 2 +- .../devtools-indicator/next-logo.stories.tsx | 82 +++ .../devtools-indicator/next-logo.tsx | 650 ++++++++++++++++++ 3 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx create mode 100644 packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx index a6932f7ab1dc..b3dbeab680e9 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx @@ -3,8 +3,8 @@ import type { OverlayState, OverlayDispatch } from '../../shared' import type { DevToolsScale } from '../errors/dev-tools-indicator/dev-tools-info/preferences' import { useState, useRef } from 'react' +import { NextLogo } from './next-logo' import { Toast } from '../toast' -import { NextLogo } from '../errors/dev-tools-indicator/next-logo' import { MENU_CURVE, MENU_DURATION_MS, diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx new file mode 100644 index 000000000000..baf0920e8a8f --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { NextLogo } from './next-logo' +import { withShadowPortal } from '../../storybook/with-shadow-portal' + +const meta: Meta = { + component: NextLogo, + parameters: { + layout: 'centered', + }, + args: { + 'aria-label': 'Open Next.js DevTools', + onClick: () => console.log('Clicked!'), + }, + decorators: [withShadowPortal], +} + +export default meta +type Story = StoryObj + +export const NoIssues: Story = { + args: { + issueCount: 0, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const SingleIssue: Story = { + args: { + issueCount: 1, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const MultipleIssues: Story = { + args: { + issueCount: 5, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const ManyIssues: Story = { + args: { + issueCount: 99, + isDevBuilding: false, + isDevRendering: false, + }, +} + +export const Building: Story = { + args: { + issueCount: 0, + isDevBuilding: true, + isDevRendering: false, + }, +} + +export const BuildingWithError: Story = { + args: { + issueCount: 1, + isDevBuilding: true, + isDevRendering: false, + }, +} + +export const Rendering: Story = { + args: { + issueCount: 0, + isDevBuilding: false, + isDevRendering: true, + }, +} + +export const RenderingWithError: Story = { + args: { + issueCount: 1, + isDevBuilding: false, + isDevRendering: true, + }, +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx new file mode 100644 index 000000000000..9a42dd03b820 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx @@ -0,0 +1,650 @@ +import { forwardRef, useEffect, useRef, useState } from 'react' +import { css } from '../../utils/css' +import mergeRefs from '../../utils/merge-refs' +import { useMinimumLoadingTimeMultiple } from '../errors/dev-tools-indicator/use-minimum-loading-time-multiple' +import type { DevToolsScale } from '../errors/dev-tools-indicator/dev-tools-info/preferences' + +interface Props extends React.ComponentProps<'button'> { + issueCount: number + isDevBuilding: boolean + isDevRendering: boolean + isBuildError: boolean + onTriggerClick: () => void + toggleErrorOverlay: () => void + scale: DevToolsScale +} + +const SHORT_DURATION_MS = 150 + +export const NextLogo = forwardRef(function NextLogo( + { + disabled, + issueCount, + isDevBuilding, + isDevRendering, + isBuildError, + onTriggerClick, + toggleErrorOverlay, + scale = 1, + ...props + }: Props, + propRef: React.Ref +) { + const SIZE = 36 / scale + + const hasError = issueCount > 0 + const [isErrorExpanded, setIsErrorExpanded] = useState(hasError) + const [dismissed, setDismissed] = useState(false) + const newErrorDetected = useUpdateAnimation(issueCount, SHORT_DURATION_MS) + + const triggerRef = useRef(null) + const ref = useRef(null) + const measuredWidth = useMeasureWidth(ref) + + const isLoading = useMinimumLoadingTimeMultiple( + isDevBuilding || isDevRendering + ) + const isExpanded = isErrorExpanded || disabled + const width = measuredWidth === 0 ? 'auto' : measuredWidth + + useEffect(() => { + setIsErrorExpanded(hasError) + }, [hasError]) + + return ( +
+ {/* Styles */} + +
+
+ {/* Children */} + {!disabled && ( + + )} + {isExpanded && ( +
+ + {!isBuildError && ( + + )} +
+ )} +
+
+
+
+ ) +}) + +function AnimateCount({ + children: count, + animate = true, + ...props +}: { + children: number + animate: boolean +}) { + return ( +
+
+ {count - 1} +
+
+ {count} +
+
+ ) +} + +function useMeasureWidth(ref: React.RefObject): number { + const [width, setWidth] = useState(0) + + useEffect(() => { + const el = ref.current + + if (!el) { + return + } + + const observer = new ResizeObserver(([{ contentRect }]) => { + setWidth(contentRect.width) + }) + + observer.observe(el) + return () => observer.disconnect() + }, [ref]) + + return width +} + +function useUpdateAnimation(issueCount: number, animationDurationMs = 0) { + const lastUpdatedTimeStamp = useRef(null) + const [animate, setAnimate] = useState(false) + + useEffect(() => { + if (issueCount > 0) { + const deltaMs = lastUpdatedTimeStamp.current + ? Date.now() - lastUpdatedTimeStamp.current + : -1 + lastUpdatedTimeStamp.current = Date.now() + + // We don't animate if `issueCount` changes too quickly + if (deltaMs <= animationDurationMs) { + return + } + + setAnimate(true) + // It is important to use a CSS transitioned state, not a CSS keyframed animation + // because if the issue count increases faster than the animation duration, it + // will abruptly stop and not transition smoothly back to its original state. + const timeoutId = window.setTimeout(() => { + setAnimate(false) + }, animationDurationMs) + + return () => { + clearTimeout(timeoutId) + } + } + }, [issueCount, animationDurationMs]) + + return animate +} + +function NextMark({ + isLoading, + isDevBuilding, +}: { + isLoading?: boolean + isDevBuilding?: boolean +}) { + const strokeColor = isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white' + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +function Warning() { + return ( + + + + ) +} + +export function Cross(props: React.SVGProps) { + return ( + + + + ) +} From c760f386caa8466c471c353e3a7933d74bc16d71 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Thu, 12 Jun 2025 18:11:44 +0200 Subject: [PATCH 2/5] better maintenance --- .../devtools-indicator/devtools-indicator.tsx | 5 +- .../hooks/use-measure-width.ts | 24 + .../use-minimum-loading-time-multiple.ts | 73 ++ .../hooks/use-update-animation.ts | 37 + .../devtools-indicator/next-logo.tsx | 772 ++++++++---------- .../next-devtools/dev-overlay/icons/cross.tsx | 19 + .../dev-overlay/icons/warning.tsx | 19 + .../dev-overlay/styles/component-styles.tsx | 2 + 8 files changed, 514 insertions(+), 437 deletions(-) create mode 100644 packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-measure-width.ts create mode 100644 packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-minimum-loading-time-multiple.ts create mode 100644 packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts create mode 100644 packages/next/src/next-devtools/dev-overlay/icons/cross.tsx create mode 100644 packages/next/src/next-devtools/dev-overlay/icons/warning.tsx diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx index b3dbeab680e9..0aa668704aea 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/devtools-indicator.tsx @@ -2,7 +2,7 @@ import type { CSSProperties } from 'react' import type { OverlayState, OverlayDispatch } from '../../shared' import type { DevToolsScale } from '../errors/dev-tools-indicator/dev-tools-info/preferences' -import { useState, useRef } from 'react' +import { useState } from 'react' import { NextLogo } from './next-logo' import { Toast } from '../toast' import { @@ -31,8 +31,6 @@ export function DevToolsIndicator({ const [open, setOpen] = useState(false) const [position, setPosition] = useState(getInitialPosition()) - const triggerRef = useRef(null) - const [vertical, horizontal] = position.split('-', 2) const toggleErrorOverlay = () => { @@ -66,7 +64,6 @@ export function DevToolsIndicator({ > {/* Trigger */} +): number { + const [width, setWidth] = useState(0) + + useEffect(() => { + const el = ref.current + + if (!el) { + return + } + + const observer = new ResizeObserver(([{ contentRect }]) => { + setWidth(contentRect.width) + }) + + observer.observe(el) + return () => observer.disconnect() + }, [ref]) + + return width +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-minimum-loading-time-multiple.ts b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-minimum-loading-time-multiple.ts new file mode 100644 index 000000000000..2ce22435db43 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-minimum-loading-time-multiple.ts @@ -0,0 +1,73 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * A React hook that ensures a loading state persists + * at least up to the next multiple of a given interval (default: 750ms). + * + * For example, if you're done loading at 1200ms, it forces you to wait + * until 1500ms. If it’s 1800ms, it waits until 2250ms, etc. + * + * @param isLoadingTrigger - Boolean that triggers the loading state + * @param interval - The time interval multiple in ms (default: 750ms) + * @returns Current loading state that respects multiples of the interval + */ +export function useMinimumLoadingTimeMultiple( + isLoadingTrigger: boolean, + interval = 750 +) { + const [isLoading, setIsLoading] = useState(false) + const loadStartTimeRef = useRef(null) + const timeoutIdRef = useRef(null) + + useEffect(() => { + // Clear any pending timeout to avoid overlap + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + timeoutIdRef.current = null + } + + if (isLoadingTrigger) { + // If we enter "loading" state, record start time if not already + if (loadStartTimeRef.current === null) { + loadStartTimeRef.current = Date.now() + } + setIsLoading(true) + } else { + // If we're exiting the "loading" state: + if (loadStartTimeRef.current === null) { + // No start time was recorded, so just stop loading immediately + setIsLoading(false) + } else { + // How long we've been "loading" + const timeDiff = Date.now() - loadStartTimeRef.current + + // Next multiple of `interval` after `timeDiff` + const nextMultiple = interval * Math.ceil(timeDiff / interval) + + // Remaining time needed to reach that multiple + const remainingTime = nextMultiple - timeDiff + + if (remainingTime > 0) { + // If not yet at that multiple, schedule the final step + timeoutIdRef.current = setTimeout(() => { + setIsLoading(false) + loadStartTimeRef.current = null + }, remainingTime) + } else { + // We're already past the multiple boundary + setIsLoading(false) + loadStartTimeRef.current = null + } + } + } + + // Cleanup when effect is about to re-run or component unmounts + return () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current) + } + } + }, [isLoadingTrigger, interval]) + + return isLoading +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts new file mode 100644 index 000000000000..92a30fecba04 --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/hooks/use-update-animation.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef, useState } from 'react' + +export function useUpdateAnimation( + issueCount: number, + animationDurationMs = 0 +) { + const lastUpdatedTimeStamp = useRef(null) + const [animate, setAnimate] = useState(false) + + useEffect(() => { + if (issueCount > 0) { + const deltaMs = lastUpdatedTimeStamp.current + ? Date.now() - lastUpdatedTimeStamp.current + : -1 + lastUpdatedTimeStamp.current = Date.now() + + // We don't animate if `issueCount` changes too quickly + if (deltaMs <= animationDurationMs) { + return + } + + setAnimate(true) + // It is important to use a CSS transitioned state, not a CSS keyframed animation + // because if the issue count increases faster than the animation duration, it + // will abruptly stop and not transition smoothly back to its original state. + const timeoutId = window.setTimeout(() => { + setAnimate(false) + }, animationDurationMs) + + return () => { + clearTimeout(timeoutId) + } + } + }, [issueCount, animationDurationMs]) + + return animate +} diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx index 9a42dd03b820..52640b702d46 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx @@ -1,9 +1,13 @@ -import { forwardRef, useEffect, useRef, useState } from 'react' -import { css } from '../../utils/css' -import mergeRefs from '../../utils/merge-refs' -import { useMinimumLoadingTimeMultiple } from '../errors/dev-tools-indicator/use-minimum-loading-time-multiple' import type { DevToolsScale } from '../errors/dev-tools-indicator/dev-tools-info/preferences' +import { useEffect, useRef, useState } from 'react' +import { useUpdateAnimation } from './hooks/use-update-animation' +import { useMeasureWidth } from './hooks/use-measure-width' +import { useMinimumLoadingTimeMultiple } from './hooks/use-minimum-loading-time-multiple' +import { Cross } from '../../icons/cross' +import { Warning } from '../../icons/warning' +import { css } from '../../utils/css' + interface Props extends React.ComponentProps<'button'> { issueCount: number isDevBuilding: boolean @@ -16,20 +20,17 @@ interface Props extends React.ComponentProps<'button'> { const SHORT_DURATION_MS = 150 -export const NextLogo = forwardRef(function NextLogo( - { - disabled, - issueCount, - isDevBuilding, - isDevRendering, - isBuildError, - onTriggerClick, - toggleErrorOverlay, - scale = 1, - ...props - }: Props, - propRef: React.Ref -) { +export function NextLogo({ + disabled, + issueCount, + isDevBuilding, + isDevRendering, + isBuildError, + onTriggerClick, + toggleErrorOverlay, + scale = 1, + ...props +}: Props) { const SIZE = 36 / scale const hasError = issueCount > 0 @@ -64,328 +65,6 @@ export const NextLogo = forwardRef(function NextLogo( } as React.CSSProperties } > - {/* Styles */} -
) -}) +} function AnimateCount({ children: count, @@ -487,60 +166,6 @@ function AnimateCount({ ) } -function useMeasureWidth(ref: React.RefObject): number { - const [width, setWidth] = useState(0) - - useEffect(() => { - const el = ref.current - - if (!el) { - return - } - - const observer = new ResizeObserver(([{ contentRect }]) => { - setWidth(contentRect.width) - }) - - observer.observe(el) - return () => observer.disconnect() - }, [ref]) - - return width -} - -function useUpdateAnimation(issueCount: number, animationDurationMs = 0) { - const lastUpdatedTimeStamp = useRef(null) - const [animate, setAnimate] = useState(false) - - useEffect(() => { - if (issueCount > 0) { - const deltaMs = lastUpdatedTimeStamp.current - ? Date.now() - lastUpdatedTimeStamp.current - : -1 - lastUpdatedTimeStamp.current = Date.now() - - // We don't animate if `issueCount` changes too quickly - if (deltaMs <= animationDurationMs) { - return - } - - setAnimate(true) - // It is important to use a CSS transitioned state, not a CSS keyframed animation - // because if the issue count increases faster than the animation duration, it - // will abruptly stop and not transition smoothly back to its original state. - const timeoutId = window.setTimeout(() => { - setAnimate(false) - }, animationDurationMs) - - return () => { - clearTimeout(timeoutId) - } - } - }, [issueCount, animationDurationMs]) - - return animate -} - function NextMark({ isLoading, isDevBuilding, @@ -610,41 +235,322 @@ function NextMark({ ) } -function Warning() { - return ( - - - - ) -} +export const NEXT_LOGO_STYLES = css` + [data-next-badge-root] { + --timing: cubic-bezier(0.23, 0.88, 0.26, 0.92); + --duration-long: 250ms; + --color-outer-border: #171717; + --color-inner-border: hsla(0, 0%, 100%, 0.14); + --color-hover-alpha-subtle: hsla(0, 0%, 100%, 0.13); + --color-hover-alpha-error: hsla(0, 0%, 100%, 0.2); + --color-hover-alpha-error-2: hsla(0, 0%, 100%, 0.25); + --mark-size: calc(var(--size) - var(--size-2) * 2); + + --focus-color: var(--color-blue-800); + --focus-ring: 2px solid var(--focus-color); + + &:has([data-next-badge][data-error='true']) { + --focus-color: #fff; + } + } + + [data-disabled-icon] { + display: flex; + align-items: center; + justify-content: center; + padding-right: 4px; + } + + [data-next-badge] { + -webkit-font-smoothing: antialiased; + width: var(--size); + height: var(--size); + display: flex; + align-items: center; + position: relative; + background: rgba(0, 0, 0, 0.8); + box-shadow: + 0 0 0 1px var(--color-outer-border), + inset 0 0 0 1px var(--color-inner-border), + 0px 16px 32px -8px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(48px); + border-radius: var(--rounded-full); + user-select: none; + cursor: pointer; + scale: 1; + overflow: hidden; + will-change: scale, box-shadow, width, background; + transition: + scale var(--duration-short) var(--timing), + width var(--duration-long) var(--timing), + box-shadow var(--duration-long) var(--timing), + background var(--duration-short) ease; + + &:active[data-error='false'] { + scale: 0.95; + } -export function Cross(props: React.SVGProps) { - return ( - - - - ) -} + &[data-animate='true']:not(:hover) { + scale: 1.02; + } + + &[data-error='false']:has([data-next-mark]:focus-visible) { + outline: var(--focus-ring); + outline-offset: 3px; + } + + &[data-error='true'] { + background: #ca2a30; + --color-inner-border: #e5484d; + + [data-next-mark] { + background: var(--color-hover-alpha-error); + outline-offset: 0px; + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: -1px; + } + + &:hover { + background: var(--color-hover-alpha-error-2); + } + } + } + + &[data-error-expanded='false'][data-error='true'] ~ [data-dot] { + scale: 1; + } + + > div { + display: flex; + } + } + + [data-issues-collapse]:focus-visible { + outline: var(--focus-ring); + } + + [data-issues]:has([data-issues-open]:focus-visible) { + outline: var(--focus-ring); + outline-offset: -1px; + } + + [data-dot] { + content: ''; + width: var(--size-8); + height: var(--size-8); + background: #fff; + box-shadow: 0 0 0 1px var(--color-outer-border); + border-radius: 50%; + position: absolute; + top: 2px; + right: 0px; + scale: 0; + pointer-events: none; + transition: scale 200ms var(--timing); + transition-delay: var(--duration-short); + } + + [data-issues] { + --padding-left: 8px; + display: flex; + gap: 2px; + align-items: center; + padding-left: 8px; + padding-right: 8px; + height: var(--size-32); + margin-right: 2px; + border-radius: var(--rounded-full); + transition: background var(--duration-short) ease; + + &:has([data-issues-open]:hover) { + background: var(--color-hover-alpha-error); + } + + &:has([data-issues-collapse]) { + padding-right: calc(var(--padding-left) / 2); + } + + [data-cross] { + translate: 0px -1px; + } + } + + [data-issues-open] { + font-size: var(--size-13); + color: white; + width: fit-content; + height: 100%; + display: flex; + gap: 2px; + align-items: center; + margin: 0; + line-height: var(--size-36); + font-weight: 500; + z-index: 2; + white-space: nowrap; + + &:focus-visible { + outline: 0; + } + } + + [data-issues-collapse] { + width: var(--size-24); + height: var(--size-24); + border-radius: var(--rounded-full); + transition: background var(--duration-short) ease; + + &:hover { + background: var(--color-hover-alpha-error); + } + } + + [data-cross] { + color: #fff; + width: var(--size-12); + height: var(--size-12); + } + + [data-next-mark] { + width: var(--mark-size); + height: var(--mark-size); + margin: 0 2px; + display: flex; + align-items: center; + border-radius: var(--rounded-full); + transition: background var(--duration-long) var(--timing); + + &:focus-visible { + outline: 0; + } + + &:hover { + background: var(--color-hover-alpha-subtle); + } + + svg { + flex-shrink: 0; + width: var(--size-40); + height: var(--size-40); + } + } + + [data-issues-count-animation] { + display: grid; + place-items: center center; + font-variant-numeric: tabular-nums; + + &[data-animate='false'] { + [data-issues-count-exit], + [data-issues-count-enter] { + animation-duration: 0ms; + } + } + + > * { + grid-area: 1 / 1; + } + + [data-issues-count-exit] { + animation: fadeOut 300ms var(--timing) forwards; + } + + [data-issues-count-enter] { + animation: fadeIn 300ms var(--timing) forwards; + } + } + + [data-issues-count-plural] { + display: inline-block; + &[data-animate='true'] { + animation: fadeIn 300ms var(--timing) forwards; + } + } + + .path0 { + animation: draw0 1.5s ease-in-out infinite; + } + + .path1 { + animation: draw1 1.5s ease-out infinite; + animation-delay: 0.3s; + } + + .paused { + stroke-dashoffset: 0; + } + + @keyframes fadeIn { + 0% { + opacity: 0; + filter: blur(2px); + transform: translateY(8px); + } + 100% { + opacity: 1; + filter: blur(0px); + transform: translateY(0); + } + } + + @keyframes fadeOut { + 0% { + opacity: 1; + filter: blur(0px); + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-12px); + filter: blur(2px); + } + } + + @keyframes draw0 { + 0%, + 25% { + stroke-dashoffset: -29.6; + } + 25%, + 50% { + stroke-dashoffset: 0; + } + 50%, + 75% { + stroke-dashoffset: 0; + } + 75%, + 100% { + stroke-dashoffset: 29.6; + } + } + + @keyframes draw1 { + 0%, + 20% { + stroke-dashoffset: -11.6; + } + 20%, + 50% { + stroke-dashoffset: 0; + } + 50%, + 75% { + stroke-dashoffset: 0; + } + 75%, + 100% { + stroke-dashoffset: 11.6; + } + } + + @media (prefers-reduced-motion) { + [data-issues-count-exit], + [data-issues-count-enter], + [data-issues-count-plural] { + animation-duration: 0ms !important; + } + } +` diff --git a/packages/next/src/next-devtools/dev-overlay/icons/cross.tsx b/packages/next/src/next-devtools/dev-overlay/icons/cross.tsx new file mode 100644 index 000000000000..578d8629967b --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/icons/cross.tsx @@ -0,0 +1,19 @@ +export function Cross(props: React.SVGProps) { + return ( + + + + ) +} diff --git a/packages/next/src/next-devtools/dev-overlay/icons/warning.tsx b/packages/next/src/next-devtools/dev-overlay/icons/warning.tsx new file mode 100644 index 000000000000..8ff102e5053d --- /dev/null +++ b/packages/next/src/next-devtools/dev-overlay/icons/warning.tsx @@ -0,0 +1,19 @@ +export function Warning(props: React.SVGProps) { + return ( + + + + ) +} diff --git a/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx b/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx index b5dc8ef24e55..0aa27ae00ec3 100644 --- a/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx +++ b/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx @@ -24,6 +24,7 @@ import { DEV_TOOLS_INFO_USER_PREFERENCES_STYLES } from '../components/errors/dev import { DEV_TOOLS_INFO_RENDER_FILES_STYLES } from '../components/overview/segment-explorer' import { FADER_STYLES } from '../components/fader' import { RESTART_SERVER_BUTTON_STYLES } from '../components/errors/error-overlay-toolbar/restart-server-button' +import { NEXT_LOGO_STYLES } from '../components/devtools-indicator/next-logo' export function ComponentStyles() { return ( @@ -54,6 +55,7 @@ export function ComponentStyles() { ${DEV_TOOLS_INFO_USER_PREFERENCES_STYLES} ${DEV_TOOLS_INFO_RENDER_FILES_STYLES} ${FADER_STYLES} + ${NEXT_LOGO_STYLES} `} ) From c9b3e24af44a12f6e0223a43c2cf8008d23c9bef Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 14 Jun 2025 20:38:54 +0200 Subject: [PATCH 3/5] put style tag back --- .../devtools-indicator/next-logo.tsx | 642 +++++++++--------- 1 file changed, 322 insertions(+), 320 deletions(-) diff --git a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx index 52640b702d46..a575bbbd1b1a 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/devtools-indicator/next-logo.tsx @@ -65,6 +65,328 @@ export function NextLogo({ } as React.CSSProperties } > + {/* Styles */} +
) } - -export const NEXT_LOGO_STYLES = css` - [data-next-badge-root] { - --timing: cubic-bezier(0.23, 0.88, 0.26, 0.92); - --duration-long: 250ms; - --color-outer-border: #171717; - --color-inner-border: hsla(0, 0%, 100%, 0.14); - --color-hover-alpha-subtle: hsla(0, 0%, 100%, 0.13); - --color-hover-alpha-error: hsla(0, 0%, 100%, 0.2); - --color-hover-alpha-error-2: hsla(0, 0%, 100%, 0.25); - --mark-size: calc(var(--size) - var(--size-2) * 2); - - --focus-color: var(--color-blue-800); - --focus-ring: 2px solid var(--focus-color); - - &:has([data-next-badge][data-error='true']) { - --focus-color: #fff; - } - } - - [data-disabled-icon] { - display: flex; - align-items: center; - justify-content: center; - padding-right: 4px; - } - - [data-next-badge] { - -webkit-font-smoothing: antialiased; - width: var(--size); - height: var(--size); - display: flex; - align-items: center; - position: relative; - background: rgba(0, 0, 0, 0.8); - box-shadow: - 0 0 0 1px var(--color-outer-border), - inset 0 0 0 1px var(--color-inner-border), - 0px 16px 32px -8px rgba(0, 0, 0, 0.24); - backdrop-filter: blur(48px); - border-radius: var(--rounded-full); - user-select: none; - cursor: pointer; - scale: 1; - overflow: hidden; - will-change: scale, box-shadow, width, background; - transition: - scale var(--duration-short) var(--timing), - width var(--duration-long) var(--timing), - box-shadow var(--duration-long) var(--timing), - background var(--duration-short) ease; - - &:active[data-error='false'] { - scale: 0.95; - } - - &[data-animate='true']:not(:hover) { - scale: 1.02; - } - - &[data-error='false']:has([data-next-mark]:focus-visible) { - outline: var(--focus-ring); - outline-offset: 3px; - } - - &[data-error='true'] { - background: #ca2a30; - --color-inner-border: #e5484d; - - [data-next-mark] { - background: var(--color-hover-alpha-error); - outline-offset: 0px; - - &:focus-visible { - outline: var(--focus-ring); - outline-offset: -1px; - } - - &:hover { - background: var(--color-hover-alpha-error-2); - } - } - } - - &[data-error-expanded='false'][data-error='true'] ~ [data-dot] { - scale: 1; - } - - > div { - display: flex; - } - } - - [data-issues-collapse]:focus-visible { - outline: var(--focus-ring); - } - - [data-issues]:has([data-issues-open]:focus-visible) { - outline: var(--focus-ring); - outline-offset: -1px; - } - - [data-dot] { - content: ''; - width: var(--size-8); - height: var(--size-8); - background: #fff; - box-shadow: 0 0 0 1px var(--color-outer-border); - border-radius: 50%; - position: absolute; - top: 2px; - right: 0px; - scale: 0; - pointer-events: none; - transition: scale 200ms var(--timing); - transition-delay: var(--duration-short); - } - - [data-issues] { - --padding-left: 8px; - display: flex; - gap: 2px; - align-items: center; - padding-left: 8px; - padding-right: 8px; - height: var(--size-32); - margin-right: 2px; - border-radius: var(--rounded-full); - transition: background var(--duration-short) ease; - - &:has([data-issues-open]:hover) { - background: var(--color-hover-alpha-error); - } - - &:has([data-issues-collapse]) { - padding-right: calc(var(--padding-left) / 2); - } - - [data-cross] { - translate: 0px -1px; - } - } - - [data-issues-open] { - font-size: var(--size-13); - color: white; - width: fit-content; - height: 100%; - display: flex; - gap: 2px; - align-items: center; - margin: 0; - line-height: var(--size-36); - font-weight: 500; - z-index: 2; - white-space: nowrap; - - &:focus-visible { - outline: 0; - } - } - - [data-issues-collapse] { - width: var(--size-24); - height: var(--size-24); - border-radius: var(--rounded-full); - transition: background var(--duration-short) ease; - - &:hover { - background: var(--color-hover-alpha-error); - } - } - - [data-cross] { - color: #fff; - width: var(--size-12); - height: var(--size-12); - } - - [data-next-mark] { - width: var(--mark-size); - height: var(--mark-size); - margin: 0 2px; - display: flex; - align-items: center; - border-radius: var(--rounded-full); - transition: background var(--duration-long) var(--timing); - - &:focus-visible { - outline: 0; - } - - &:hover { - background: var(--color-hover-alpha-subtle); - } - - svg { - flex-shrink: 0; - width: var(--size-40); - height: var(--size-40); - } - } - - [data-issues-count-animation] { - display: grid; - place-items: center center; - font-variant-numeric: tabular-nums; - - &[data-animate='false'] { - [data-issues-count-exit], - [data-issues-count-enter] { - animation-duration: 0ms; - } - } - - > * { - grid-area: 1 / 1; - } - - [data-issues-count-exit] { - animation: fadeOut 300ms var(--timing) forwards; - } - - [data-issues-count-enter] { - animation: fadeIn 300ms var(--timing) forwards; - } - } - - [data-issues-count-plural] { - display: inline-block; - &[data-animate='true'] { - animation: fadeIn 300ms var(--timing) forwards; - } - } - - .path0 { - animation: draw0 1.5s ease-in-out infinite; - } - - .path1 { - animation: draw1 1.5s ease-out infinite; - animation-delay: 0.3s; - } - - .paused { - stroke-dashoffset: 0; - } - - @keyframes fadeIn { - 0% { - opacity: 0; - filter: blur(2px); - transform: translateY(8px); - } - 100% { - opacity: 1; - filter: blur(0px); - transform: translateY(0); - } - } - - @keyframes fadeOut { - 0% { - opacity: 1; - filter: blur(0px); - transform: translateY(0); - } - 100% { - opacity: 0; - transform: translateY(-12px); - filter: blur(2px); - } - } - - @keyframes draw0 { - 0%, - 25% { - stroke-dashoffset: -29.6; - } - 25%, - 50% { - stroke-dashoffset: 0; - } - 50%, - 75% { - stroke-dashoffset: 0; - } - 75%, - 100% { - stroke-dashoffset: 29.6; - } - } - - @keyframes draw1 { - 0%, - 20% { - stroke-dashoffset: -11.6; - } - 20%, - 50% { - stroke-dashoffset: 0; - } - 50%, - 75% { - stroke-dashoffset: 0; - } - 75%, - 100% { - stroke-dashoffset: 11.6; - } - } - - @media (prefers-reduced-motion) { - [data-issues-count-exit], - [data-issues-count-enter], - [data-issues-count-plural] { - animation-duration: 0ms !important; - } - } -` From 2ecd023357e5eca5c1d448bec81c6e3b7d2f5374 Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Sat, 14 Jun 2025 20:52:09 +0200 Subject: [PATCH 4/5] remove ported style tag --- .../src/next-devtools/dev-overlay/styles/component-styles.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx b/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx index 0aa27ae00ec3..b5dc8ef24e55 100644 --- a/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx +++ b/packages/next/src/next-devtools/dev-overlay/styles/component-styles.tsx @@ -24,7 +24,6 @@ import { DEV_TOOLS_INFO_USER_PREFERENCES_STYLES } from '../components/errors/dev import { DEV_TOOLS_INFO_RENDER_FILES_STYLES } from '../components/overview/segment-explorer' import { FADER_STYLES } from '../components/fader' import { RESTART_SERVER_BUTTON_STYLES } from '../components/errors/error-overlay-toolbar/restart-server-button' -import { NEXT_LOGO_STYLES } from '../components/devtools-indicator/next-logo' export function ComponentStyles() { return ( @@ -55,7 +54,6 @@ export function ComponentStyles() { ${DEV_TOOLS_INFO_USER_PREFERENCES_STYLES} ${DEV_TOOLS_INFO_RENDER_FILES_STYLES} ${FADER_STYLES} - ${NEXT_LOGO_STYLES} `} ) From 3a1ff9901ca95d0f421c249db8e309216bdc8fae Mon Sep 17 00:00:00 2001 From: devjiwonchoi Date: Tue, 17 Jun 2025 18:21:16 +0200 Subject: [PATCH 5/5] share hooks --- .../errors/dev-tools-indicator/next-logo.tsx | 58 +-------------- .../use-minimum-loading-time-multiple.tsx | 73 ------------------- 2 files changed, 3 insertions(+), 128 deletions(-) delete mode 100644 packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/use-minimum-loading-time-multiple.tsx diff --git a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx b/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx index 10ff35365ae3..6f59f4c7a1cf 100644 --- a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx +++ b/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/next-logo.tsx @@ -1,8 +1,10 @@ import { forwardRef, useEffect, useRef, useState } from 'react' import { css } from '../../../utils/css' import mergeRefs from '../../../utils/merge-refs' -import { useMinimumLoadingTimeMultiple } from './use-minimum-loading-time-multiple' import type { DevToolsScale } from './dev-tools-info/preferences' +import { useMinimumLoadingTimeMultiple } from '../../devtools-indicator/hooks/use-minimum-loading-time-multiple' +import { useUpdateAnimation } from '../../devtools-indicator/hooks/use-update-animation' +import { useMeasureWidth } from '../../devtools-indicator/hooks/use-measure-width' interface Props extends React.ComponentProps<'button'> { issueCount: number @@ -487,60 +489,6 @@ function AnimateCount({ ) } -function useMeasureWidth(ref: React.RefObject): number { - const [width, setWidth] = useState(0) - - useEffect(() => { - const el = ref.current - - if (!el) { - return - } - - const observer = new ResizeObserver(([{ contentRect }]) => { - setWidth(contentRect.width) - }) - - observer.observe(el) - return () => observer.disconnect() - }, [ref]) - - return width -} - -function useUpdateAnimation(issueCount: number, animationDurationMs = 0) { - const lastUpdatedTimeStamp = useRef(null) - const [animate, setAnimate] = useState(false) - - useEffect(() => { - if (issueCount > 0) { - const deltaMs = lastUpdatedTimeStamp.current - ? Date.now() - lastUpdatedTimeStamp.current - : -1 - lastUpdatedTimeStamp.current = Date.now() - - // We don't animate if `issueCount` changes too quickly - if (deltaMs <= animationDurationMs) { - return - } - - setAnimate(true) - // It is important to use a CSS transitioned state, not a CSS keyframed animation - // because if the issue count increases faster than the animation duration, it - // will abruptly stop and not transition smoothly back to its original state. - const timeoutId = window.setTimeout(() => { - setAnimate(false) - }, animationDurationMs) - - return () => { - clearTimeout(timeoutId) - } - } - }, [issueCount, animationDurationMs]) - - return animate -} - function NextMark({ isLoading, isDevBuilding, diff --git a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/use-minimum-loading-time-multiple.tsx b/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/use-minimum-loading-time-multiple.tsx deleted file mode 100644 index 2ce22435db43..000000000000 --- a/packages/next/src/next-devtools/dev-overlay/components/errors/dev-tools-indicator/use-minimum-loading-time-multiple.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useEffect, useRef, useState } from 'react' - -/** - * A React hook that ensures a loading state persists - * at least up to the next multiple of a given interval (default: 750ms). - * - * For example, if you're done loading at 1200ms, it forces you to wait - * until 1500ms. If it’s 1800ms, it waits until 2250ms, etc. - * - * @param isLoadingTrigger - Boolean that triggers the loading state - * @param interval - The time interval multiple in ms (default: 750ms) - * @returns Current loading state that respects multiples of the interval - */ -export function useMinimumLoadingTimeMultiple( - isLoadingTrigger: boolean, - interval = 750 -) { - const [isLoading, setIsLoading] = useState(false) - const loadStartTimeRef = useRef(null) - const timeoutIdRef = useRef(null) - - useEffect(() => { - // Clear any pending timeout to avoid overlap - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current) - timeoutIdRef.current = null - } - - if (isLoadingTrigger) { - // If we enter "loading" state, record start time if not already - if (loadStartTimeRef.current === null) { - loadStartTimeRef.current = Date.now() - } - setIsLoading(true) - } else { - // If we're exiting the "loading" state: - if (loadStartTimeRef.current === null) { - // No start time was recorded, so just stop loading immediately - setIsLoading(false) - } else { - // How long we've been "loading" - const timeDiff = Date.now() - loadStartTimeRef.current - - // Next multiple of `interval` after `timeDiff` - const nextMultiple = interval * Math.ceil(timeDiff / interval) - - // Remaining time needed to reach that multiple - const remainingTime = nextMultiple - timeDiff - - if (remainingTime > 0) { - // If not yet at that multiple, schedule the final step - timeoutIdRef.current = setTimeout(() => { - setIsLoading(false) - loadStartTimeRef.current = null - }, remainingTime) - } else { - // We're already past the multiple boundary - setIsLoading(false) - loadStartTimeRef.current = null - } - } - } - - // Cleanup when effect is about to re-run or component unmounts - return () => { - if (timeoutIdRef.current) { - clearTimeout(timeoutIdRef.current) - } - } - }, [isLoadingTrigger, interval]) - - return isLoading -}