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.
-
-
- 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}
+
+
+ ›
+
+
+
+ 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}
>
);