diff --git a/docs/partials/intro/_about-smart-label-capture.mdx b/docs/partials/intro/_about-smart-label-capture.mdx index abd6028b..077d1c0e 100644 --- a/docs/partials/intro/_about-smart-label-capture.mdx +++ b/docs/partials/intro/_about-smart-label-capture.mdx @@ -2,11 +2,6 @@ Smart Label Capture enables the simultaneous scanning of multiple barcodes and p This technology is particularly beneficial in scenarios where labels contain various data points, such as serial numbers, weights, or expiry dates. -

- Label Capture result for scanning smart device labels -
Capturing multiple barcodes and text data from labels in a single scan -

- ## Understanding Labels in Smart Label Capture The first step to using Smart Label Capture is to [define the labels](../label-definitions) that you want to capture. diff --git a/src/components/SkillsCallout/InstallTabs.tsx b/src/components/SkillsCallout/InstallTabs.tsx index 2afdf6b9..7e1edf86 100644 --- a/src/components/SkillsCallout/InstallTabs.tsx +++ b/src/components/SkillsCallout/InstallTabs.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import skillsData from '@site/src/data/skills.json'; +import { capturePostHogEvent } from './analytics'; import styles from './styles.module.css'; type TabKey = 'any' | 'claude-code' | 'cursor'; @@ -50,14 +51,6 @@ interface CommandBlockProps { framework?: string; } -type PostHogCapture = (event: string, props?: Record) => void; - -function capturePostHogEvent(event: string, props?: Record): void { - if (typeof window === 'undefined') return; - const ph = (window as unknown as { posthog?: { capture?: PostHogCapture } }).posthog; - ph?.capture?.(event, props); -} - const CommandBlock: React.FC = ({ command, trackingId, product, framework }) => { const [copied, setCopied] = useState(false); const handleCopy = async () => { diff --git a/src/components/SkillsCallout/analytics.ts b/src/components/SkillsCallout/analytics.ts new file mode 100644 index 00000000..020668f2 --- /dev/null +++ b/src/components/SkillsCallout/analytics.ts @@ -0,0 +1,7 @@ +type PostHogCapture = (event: string, props?: Record) => void; + +export function capturePostHogEvent(event: string, props?: Record): void { + if (typeof window === 'undefined') return; + const ph = (window as unknown as { posthog?: { capture?: PostHogCapture } }).posthog; + ph?.capture?.(event, props); +} diff --git a/src/components/SkillsCallout/index.tsx b/src/components/SkillsCallout/index.tsx index df01410a..3d2a71e8 100644 --- a/src/components/SkillsCallout/index.tsx +++ b/src/components/SkillsCallout/index.tsx @@ -4,9 +4,13 @@ import { useLocation } from '@docusaurus/router'; import skillsData from '@site/src/data/skills.json'; import productsData from '@site/src/data/products.json'; import { parseSdksRoute } from '../utils/frameworks'; +import { capturePostHogEvent } from './analytics'; import InstallTabs from './InstallTabs'; import styles from './styles.module.css'; +const PRODUCT_DISAMBIGUATION_HEADING = + 'Not sure which Scandit product fits your use case?'; + interface SkillsCalloutProps { product?: string; framework?: string; @@ -68,32 +72,93 @@ function getSharedMoreInfoUrl(search: string): string { return `/sdks/${path}/agent-skills`; } +interface CalloutDetailsProps { + heading: string; + banner?: boolean; + trackingProps: Record; + children: React.ReactNode; +} + +const CalloutDetails: React.FC = ({ + heading, + banner = false, + trackingProps, + children, +}) => { + const className = banner ? `${styles.callout} ${styles.banner}` : styles.callout; + const handleToggle: React.ReactEventHandler = (e) => { + if (!e.currentTarget.open) return; + capturePostHogEvent('skills_callout_expanded', trackingProps); + }; + // Cursor-follow spotlight: write the mouse position into CSS variables on + // the element so the radial-gradient ::before can read them. + const handleMouseMove: React.MouseEventHandler = (e) => { + const rect = e.currentTarget.getBoundingClientRect(); + e.currentTarget.style.setProperty('--callout-mx', `${e.clientX - rect.left}px`); + e.currentTarget.style.setProperty('--callout-my', `${e.clientY - rect.top}px`); + }; + return ( +
+ + {heading} + +
{children}
+
+ ); +}; + +interface SharedBodyProps { + sharedFrameworkSlug: string; + sharedMoreInfoUrl: string; +} + +const SharedBody: React.FC = ({ + sharedFrameworkSlug, + sharedMoreInfoUrl, +}) => ( + <> +

+ Install our {skillsData.shared} skill so your coding + agent can answer questions about Scandit products and recommend + the right one for your use case, directly from your editor.{' '} + More info → +

+ + +); + const SkillsCallout: React.FC = ({ product, framework, variant = 'product', banner = false }) => { const { pathname, search } = useLocation(); - const calloutClass = banner ? `${styles.callout} ${styles.banner}` : styles.callout; - const contentClass = banner ? styles.bannerContent : undefined; - if (variant === 'shared') { const sharedFrameworkSlug = getSharedFrameworkSlug(search); const sharedMoreInfoUrl = getSharedMoreInfoUrl(search); return ( - + + + ); } @@ -117,8 +182,15 @@ const SkillsCallout: React.FC = ({ product, framework, varia const moreInfoUrl = frameworkPath ? `/sdks/${frameworkPath}/agent-skills` : null; return ( - + ); }; diff --git a/src/components/SkillsCallout/routes.ts b/src/components/SkillsCallout/routes.ts new file mode 100644 index 00000000..7d3f715c --- /dev/null +++ b/src/components/SkillsCallout/routes.ts @@ -0,0 +1,14 @@ +// Returns true when the page should NOT show the fallback +// "Not sure which Scandit product fits your use case?" disclosure. +// +// The rule is anchored to the final non-empty path segment so unrelated +// paths like /foo/migrate-tool/bar are not caught. +export function isOnFallbackDenylist(pathname: string): boolean { + const segments = pathname.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + if (!last) return false; + if (last === 'release-notes') return true; + if (last === 'agent-skills') return true; + if (last.startsWith('migrate-')) return true; + return false; +} diff --git a/src/components/SkillsCallout/styles.module.css b/src/components/SkillsCallout/styles.module.css index 4350e09c..4bb76aba 100644 --- a/src/components/SkillsCallout/styles.module.css +++ b/src/components/SkillsCallout/styles.module.css @@ -1,23 +1,97 @@ .callout { display: block; + position: relative; + overflow: hidden; margin: 0 0 2rem 0; - padding: 1.25rem 1.5rem; + /* padding-top is constant across collapsed/open so the title doesn't + shift vertically when expanding. padding-bottom flexes via [open] + below: tighter in the collapsed state for optical centering of the + single summary line, restored when there's body content beneath. */ + padding: 1.3rem 1.5rem 1.05rem; border-radius: 8px; background: var(--light-blue-60, #f0f5ff); - border-left: 4px solid var(--light-blue-100, #2385ec); - border-top: 1px solid rgba(35, 133, 236, 0.15); - border-right: 1px solid rgba(35, 133, 236, 0.15); - border-bottom: 1px solid rgba(35, 133, 236, 0.15); + border: 1px solid rgba(35, 133, 236, 0.18); font-family: var(--font-family, inherit); + /* Default spotlight origin (centered) before the first mousemove. */ + --callout-mx: 50%; + --callout-my: 50%; + /* Enable height: auto ↔ 0 transitions on the ::details-content + pseudo-element below (Chrome 129+, Safari 18+). Older browsers + gracefully snap open with no animation. */ + interpolate-size: allow-keywords; } -.banner { - margin: 0; - padding: 1.75rem 2rem; +/* Smooth open/close animation. ::details-content is the pseudo-element + the browser provides for the disclosure body; transitioning its height + and opacity (with content-visibility transitioning as a discrete + property so the content is rendered during the animation) gives a + clean slide-and-fade. @starting-style defines the "entering" state. + Longhand transition syntax — the shorthand with `allow-discrete` is + stripped by the build's CSS minifier. */ +.callout::details-content { + overflow-y: clip; + height: 0; + opacity: 0; + transition-property: height, opacity, content-visibility; + transition-duration: 280ms, 220ms, 280ms; + transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + transition-behavior: allow-discrete; +} + +.callout[open]::details-content { + height: auto; + opacity: 1; +} + +@starting-style { + .callout[open]::details-content { + height: 0; + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .callout::details-content { + transition: none; + } +} + +/* Cursor-follow spotlight. Sits above the background, below the content. + Only revealed when collapsed + hovered — once expanded, the user is + reading and we don't want chrome competing with content. */ +.callout::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + 480px circle at var(--callout-mx) var(--callout-my), + rgba(35, 133, 236, 0.12), + rgba(35, 133, 236, 0) 55% + ); + opacity: 0; + transition: opacity 220ms cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: none; + z-index: 0; +} + +details.callout:hover::before { + opacity: 1; +} + +html[data-theme='dark'] .callout::before { + background: radial-gradient( + 480px circle at var(--callout-mx) var(--callout-my), + rgba(35, 133, 236, 0.18), + rgba(35, 133, 236, 0) 55% + ); } -.bannerContent { - max-width: 100%; +details.callout[open] { + padding-bottom: 1.25rem; +} + +.banner { + margin: 0; } .title { @@ -30,7 +104,7 @@ .description { margin: 0 0 0.75rem 0; font-size: 1rem; - line-height: 1.5; + line-height: 1.65; color: var(--ifm-color-content-secondary, #444); } @@ -47,7 +121,9 @@ border: 1px solid var(--light-blue-80, #c2cce3); border-radius: 6px; font-family: var(--third-family, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); - font-size: 1rem; + /* Monospace at 1rem optically reads larger than proportional body text; + scaling to 0.9375rem matches the body visually. */ + font-size: 0.9375rem; line-height: 1.5; overflow-x: auto; white-space: pre-wrap; @@ -144,7 +220,7 @@ html[data-theme='dark'] .copyButton:hover { .tabHint { margin: 0 0 0.5rem 0; font-size: 1rem; - line-height: 1.5; + line-height: 1.65; color: var(--ifm-color-content-secondary, #444); } @@ -177,10 +253,7 @@ html[data-theme='dark'] .copyButton:hover { html[data-theme='dark'] .callout { background: rgba(35, 133, 236, 0.08); - border-left-color: var(--dark-blue-accent, #2385ec); - border-top-color: rgba(35, 133, 236, 0.2); - border-right-color: rgba(35, 133, 236, 0.2); - border-bottom-color: rgba(35, 133, 236, 0.2); + border-color: rgba(35, 133, 236, 0.22); } html[data-theme='dark'] .title { @@ -205,3 +278,91 @@ html[data-theme='dark'] .tab:hover, html[data-theme='dark'] .tabActive { color: var(--dark-blue-accent, #2385ec); } + +/* Beat Docusaurus's "#__docusaurus [class^=docMainContainer_] details { background-color: transparent }" + override that would otherwise wipe the blue callout background on doc pages. */ +details.callout { + background: var(--light-blue-60, #f0f5ff) !important; +} + +html[data-theme='dark'] details.callout { + background: rgba(35, 133, 236, 0.08) !important; +} + +.calloutSummary { + position: relative; + z-index: 1; + margin: 0; + list-style: none; + cursor: pointer; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; +} + +.calloutSummary::-webkit-details-marker { + display: none; +} + +.calloutHeading { + flex: 1 1 auto; + min-width: 0; +} + +.calloutHint { + flex: 0 0 auto; + display: inline-flex; + align-items: baseline; + gap: 0.4rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--light-blue-100, #2385ec); + transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1); +} + +details.callout:not([open]) .calloutSummary:hover .calloutHint, +details.callout:not([open]) .calloutSummary:focus-visible .calloutHint { + transform: translateX(4px); +} + +.calloutHintText::before { + content: 'Show'; +} + +details.callout[open] .calloutHintText::before { + content: 'Hide'; +} + +.calloutChevron { + display: inline-block; + font-size: 1rem; + line-height: 1; + transform: rotate(90deg); + transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1); +} + +details.callout[open] .calloutChevron { + transform: rotate(-90deg); +} + +.calloutBody { + position: relative; + z-index: 1; + padding: 0.75rem 0 0; +} + +/* Focus state for keyboard users — the spotlight does the visual work + for hover, so we only need an explicit focus indicator here. */ + +.calloutSummary:focus-visible { + outline: 2px solid var(--light-blue-100, #2385ec); + outline-offset: 2px; + border-radius: 4px; +} + +html[data-theme='dark'] .calloutHint { + color: var(--dark-blue-accent, #2385ec); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index bff45cee..309d38c2 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -35,12 +35,12 @@ export default function HomePage() { - -
+ +
diff --git a/src/theme/DocItem/Content/index.tsx b/src/theme/DocItem/Content/index.tsx index 6297adf9..01f190fc 100644 --- a/src/theme/DocItem/Content/index.tsx +++ b/src/theme/DocItem/Content/index.tsx @@ -1,16 +1,35 @@ import React from 'react'; +import { useLocation } from '@docusaurus/router'; import Content from '@theme-original/DocItem/Content'; import type ContentType from '@theme/DocItem/Content'; import type { WrapperProps } from '@docusaurus/types'; import SkillsCallout from '@site/src/components/SkillsCallout'; +import skillsData from '@site/src/data/skills.json'; +import { parseSdksRoute } from '@site/src/components/utils/frameworks'; +import { isOnFallbackDenylist } from '@site/src/components/SkillsCallout/routes'; type Props = WrapperProps; +const KNOWN_PRODUCTS = new Set(Object.keys(skillsData.products)); + export default function ContentWrapper(props: Props): JSX.Element { + const { pathname } = useLocation(); + const route = parseSdksRoute(pathname); + const isKnownProductPage = !!route.product && KNOWN_PRODUCTS.has(route.product); + + let callout: JSX.Element | null; + if (isKnownProductPage) { + callout = ; + } else if (isOnFallbackDenylist(pathname)) { + callout = null; + } else { + callout = ; + } + return ( <> - + {callout} );