diff --git a/components/DashboardShowcase.js b/components/DashboardShowcase.js index e3c7a553..16f5d440 100644 --- a/components/DashboardShowcase.js +++ b/components/DashboardShowcase.js @@ -74,6 +74,7 @@ export default function DashboardShowcase() { // chosen slide its full dwell time instead of flipping a moment later. const [cycle, setCycle] = useState(0) const count = SLIDES.length + const nextIndex = (active + 1) % count // The showcase auto-advances for everyone, including visitors with // "reduce motion" enabled. This is a deliberate product decision (matching @@ -218,7 +219,12 @@ export default function DashboardShowcase() { data-testid="dashboard-stage" ref={stageRef} > - {SLIDES.map((slide, index) => ( + {SLIDES.map((slide, index) => ({ slide, index })) + .filter( + ({ index }) => + index === active || index === nextIndex + ) + .map(({ slide, index }) => ( { @@ -229,7 +235,8 @@ export default function DashboardShowcase() { width={slide.width} height={slide.height} alt={slide.alt} - loading="lazy" + loading={index === active ? 'eager' : 'lazy'} + fetchPriority={index === active ? 'high' : 'low'} decoding="async" aria-hidden={index !== active} data-testid="dashboard-slide" @@ -241,7 +248,7 @@ export default function DashboardShowcase() { opacity: index === active ? 1 : 0, }} /> - ))} + ))} @@ -277,16 +284,27 @@ export default function DashboardShowcase() { onClick={() => jumpTo(index)} > - + {index === active || index === nextIndex ? ( + + ) : ( + + )} {index === active && ( { - if (typeof window === 'undefined' || !window.matchMedia) return - const media = window.matchMedia('(prefers-reduced-motion: reduce)') - const update = () => setReduced(media.matches) - update() - media.addEventListener?.('change', update) - return () => media.removeEventListener?.('change', update) - }, []) - return reduced -} - -// The honest, live-ticking "GitHub · updated Ns ago" line. The relative time -// is recomputed locally (no network) on a gentle cadence: 1s normally so the -// seconds counter reads true, or 30s under prefers-reduced-motion so there is -// no visible per-second animation. The tick pauses while the tab is hidden and -// snaps to the current time when it returns. `suppressHydrationWarning` is the -// documented escape hatch for timestamps whose server and client renders may -// legitimately differ by a second. +// Repository counts refresh at build/ISR time. A visitor does not need a +// repeating timer or a client-side fetch for a value that changes over hours. function RepositorySource({ stats }) { - const reducedMotion = usePrefersReducedMotion() - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - const cadence = reducedMotion ? 30 * 1000 : 1000 - let timer = null - const tick = () => setNow(Date.now()) - const start = () => { - if (document.visibilityState === 'hidden') return - timer = setInterval(tick, cadence) - } - const onVisibility = () => { - clearInterval(timer) - if (document.visibilityState === 'visible') { - tick() - start() - } - } - start() - document.addEventListener('visibilitychange', onVisibility) - return () => { - clearInterval(timer) - document.removeEventListener('visibilitychange', onVisibility) - } - }, [reducedMotion]) - - const label = sourceLabel(stats, now) + const label = sourceLabel(stats, Date.now()) return (

diff --git a/components/WorkflowQualificationForm.js b/components/WorkflowQualificationForm.js index ab11376c..beb519c9 100644 --- a/components/WorkflowQualificationForm.js +++ b/components/WorkflowQualificationForm.js @@ -3,6 +3,7 @@ import Link from 'next/link' import { QUALIFICATION_TIERS, + buildQualificationSalesTask, scoreWorkflowQualification, } from '../lib/workflowQualification.mjs' import { EVENTS, track } from '../utils/analytics' @@ -87,13 +88,20 @@ export default function WorkflowQualificationForm({ compact = false }) { setError('') const qualification = scoreWorkflowQualification(form) + const salesTask = buildQualificationSalesTask({ + id: `qualification_${globalThis.crypto.randomUUID()}`, + qualification, + sourceRoute: compact ? '/' : '/qualify', + submittedAt: new Date().toISOString(), + }) const data = new URLSearchParams() data.set('form-name', 'workflow-qualification') for (const [key, value] of Object.entries(form)) { data.set(key === 'botField' ? 'bot-field' : key, value) } - data.set('qualificationScore', String(qualification.score)) - data.set('qualificationTier', qualification.tier) + for (const [key, value] of Object.entries(salesTask)) { + data.set(key, value) + } try { const response = await fetch('/form.html', { @@ -126,7 +134,7 @@ export default function WorkflowQualificationForm({ compact = false }) { : 'qualification_form', tier: qualification.tier, }) - setResult(qualification) + setResult({ ...qualification, salesTask }) setState('submitted') } catch (submitError) { console.error(submitError) @@ -147,6 +155,9 @@ export default function WorkflowQualificationForm({ compact = false }) {

{next.message}

+

+ Reference: {result.salesTask.salesTaskId} +

{external ? ( { cy.get('#cloud-product').scrollIntoView().should('be.visible') cy.get('[data-testid="dashboard-product-preview"]').within(() => { + // Keep only the active capture and its successor in the DOM. This + // preserves the automatic transition without loading every large + // dashboard capture during the first visit. + cy.get('[data-testid="dashboard-slide"]').should('have.length', 2) + cy.get('[data-testid="dashboard-slide"][data-active="true"]') + .should('have.length', 1) + .and('have.attr', 'loading', 'eager') + cy.get( + '[data-testid="dashboard-slide"]:not([data-active="true"])' + ) + .should('have.length', 1) + .and('have.attr', 'loading', 'lazy') + // Every discovered tab must activate its matching, decoded slide. - cy.get('[data-testid="dashboard-slide"]').each(($img) => { - cy.wrap($img).should('have.attr', 'loading', 'lazy') - }) cy.get('[data-testid="dashboard-tab"]') .should('have.length.at.least', 1) .then(($tabs) => { - cy.get('[data-testid="dashboard-slide"]').should( - 'have.length', - $tabs.length - ) cy.get('[data-testid="dashboard-dots"] button').should( 'have.length', $tabs.length @@ -28,6 +34,7 @@ describe('Cloud product showcase', () => { cy.get('[data-testid="dashboard-slide"][data-active="true"]') .should('have.length', 1) .and('have.attr', 'data-slide', slide) + .and('have.attr', 'loading', 'eager') .should(($img) => expect($img[0].naturalWidth).to.be.greaterThan(0) ) diff --git a/cypress/e2e/public-surface-coherence.cy.js b/cypress/e2e/public-surface-coherence.cy.js index d237ec17..add4a1c3 100644 --- a/cypress/e2e/public-surface-coherence.cy.js +++ b/cypress/e2e/public-surface-coherence.cy.js @@ -20,21 +20,7 @@ describe('public surface coherence', () => { it('routes buyers and developers to the intended entry points', () => { cy.viewport(1280, 1000) - // Keep this homepage visit self-contained. Without an immediate - // response, its asynchronous stats request can outlive the test and - // satisfy the next test's intercept before that test's own visit. - cy.intercept('GET', '/api/repository-stats', { - statusCode: 200, - body: { - stars: 1650, - forks: 259, - observedAt: '2026-07-24T11:00:00.000Z', - source: 'github', - stale: false, - }, - }).as('initialHomepageRepositoryStats') cy.visit('/') - cy.wait('@initialHomepageRepositoryStats') cy.get('nav[aria-label="Primary"]') .contains('a', 'Open source') @@ -62,51 +48,24 @@ describe('public surface coherence', () => { ) }) - it('updates homepage hero and footer from one repository-stats response', () => { - cy.intercept('GET', '/api/repository-stats', { - statusCode: 200, - body: { - stars: 1660, - forks: 260, - observedAt: new Date().toISOString(), - source: 'github', - stale: false, - }, - }).as('homepageRepositoryStats') - + it('renders the same repository counts in the hero and footer', () => { cy.visit('/') - cy.wait('@homepageRepositoryStats') - - cy.get('[data-testid="github-proof"]') - .should('contain.text', '1.7k stars on OpenAdapt') - .and('contain.text', '260 forks') - cy.get('[data-testid="footer-star-count"]').should( - 'have.text', - '1,660' - ) - cy.get('[data-testid="footer-fork-count"]').should('have.text', '260') - cy.get('@homepageRepositoryStats.all').should('have.length', 1) + cy.get('[data-testid="github-proof"]').should('contain.text', 'stars on OpenAdapt') + cy.get('[data-testid="footer-star-count"]') + .invoke('text') + .should('match', /^\d{1,3}(,\d{3})*$/) + cy.get('[data-testid="footer-fork-count"]') + .invoke('text') + .should('match', /^\d{1,3}(,\d{3})*$/) }) it('keeps flagship star and fork counts visible across solution pages', () => { - cy.intercept('GET', '/api/repository-stats', { - statusCode: 200, - body: { - stars: 1650, - forks: 259, - observedAt: '2026-07-18T12:00:00.000Z', - source: 'github', - stale: false, - }, - }).as('repositoryStats') - for (const path of [ '/solutions/healthcare', '/solutions/lending', '/solutions/insurance', ]) { cy.visit(path) - cy.wait('@repositoryStats') cy.get('[data-testid="footer-repository-stats"]') .scrollIntoView() .should('be.visible') @@ -132,13 +91,11 @@ describe('public surface coherence', () => { 'a[href="https://github.com/OpenAdaptAI/OpenAdapt"]' ).should('exist') }) - // Honest, live-updating attribution tied to the actual last - // successful fetch: "GitHub · updated just now / Ns ago / ...". cy.get('[data-testid="footer-repository-source"]') .invoke('text') .should( 'match', - /^GitHub · updated (?:just now|\d+[smhd] ago)$/ + /^GitHub · (?:updated (?:just now|\d+[smhd] ago)|last-known counts)$/ ) } @@ -147,33 +104,4 @@ describe('public surface coherence', () => { ) }) - it('never blanks footer counts when the refresh endpoint is unavailable', () => { - cy.intercept('GET', '/api/repository-stats', { - statusCode: 503, - body: { error: 'unavailable' }, - }) - cy.visit('/solutions/healthcare') - - cy.get('[data-testid="footer-repository-stats"]') - .scrollIntoView() - .should('be.visible') - .within(() => { - cy.get('[data-testid="footer-star-count"]').should( - 'have.text', - '1,648' - ) - cy.get('[data-testid="footer-fork-count"]').should( - 'have.text', - '258' - ) - }) - // The refresh failed, so the committed snapshot survives and is - // labelled honestly as last-known counts rather than a fresh fetch. The - // committed fallback carries no real observation time, so it shows a - // stable label instead of a drifting "snapshot from Nd ago". - cy.get('[data-testid="footer-repository-source"]').should( - 'contain.text', - 'GitHub · last-known counts' - ) - }) }) diff --git a/data/published-version-claims.json b/data/published-version-claims.json index 2582b407..4c9a72bd 100644 --- a/data/published-version-claims.json +++ b/data/published-version-claims.json @@ -32,8 +32,8 @@ "id": "status-manifest-component-versions", "kind": "pypi-latest", "source_of_truth": "public/status.json#/versions", - "verified_on": "2026-07-31", - "evidence": "https://pypi.org/pypi//json info.version on 2026-07-31: openadapt 1.10.3, openadapt-flow 1.27.1, openadapt-capture 1.2.2, openadapt-desktop 0.14.0.", + "verified_on": "2026-08-01", + "evidence": "Published package and GitHub release records on 2026-08-01: openadapt 1.10.3, openadapt-flow 1.27.1, openadapt-capture 1.2.2, openadapt-desktop 0.15.0.", "packages": { "launcher": "openadapt", "flow": "openadapt-flow", @@ -44,7 +44,7 @@ "launcher": "1.10.3", "flow": "1.27.1", "capture": "1.2.2", - "desktop": "0.14.0" + "desktop": "0.15.0" } }, { diff --git a/hooks/useRepositoryStats.js b/hooks/useRepositoryStats.js deleted file mode 100644 index bd655059..00000000 --- a/hooks/useRepositoryStats.js +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect, useState } from 'react' - -import { OPENADAPT_STATS_SNAPSHOT } from 'data/repositoryStats' -import repositoryStatsSelection from 'utils/repositoryStatsSelection' - -const { newerStats, validStats } = repositoryStatsSelection - -// Stars/forks move over hours. Poll the same-origin endpoint while the page is -// visible; Netlify's ten-minute durable cache (s-maxage=600) means normal -// visitor refreshes do not spend GitHub's unauthenticated API quota. -const POLL_INTERVAL_MS = 90 * 1000 -const MAX_BACKOFF_MS = 10 * 60 * 1000 - -export default function useRepositoryStats( - initialStats = OPENADAPT_STATS_SNAPSHOT, - { enabled = true } = {} -) { - const initial = validStats(initialStats) - ? initialStats - : OPENADAPT_STATS_SNAPSHOT - const [stats, setStats] = useState(initial) - - useEffect(() => { - setStats((current) => newerStats(current, initial)) - }, [initial]) - - useEffect(() => { - if (!enabled) return undefined - - // Never blank the widget: failed or older responses preserve the last - // good observation and progressively back off. - let cancelled = false - let timer = null - let controller = null - let backoff = POLL_INTERVAL_MS - - const schedule = () => { - clearTimeout(timer) - if (document.visibilityState === 'hidden') return - timer = setTimeout(fetchOnce, backoff) - } - - const fetchOnce = async () => { - controller = new AbortController() - try { - const response = await fetch('/api/repository-stats', { - headers: { Accept: 'application/json' }, - signal: controller.signal, - }) - if (!response.ok) { - throw new Error(`stats request failed: ${response.status}`) - } - const next = await response.json() - if (cancelled) return - setStats((current) => newerStats(current, next)) - if (next?.source === 'github' && !next.stale) { - backoff = POLL_INTERVAL_MS - } else { - backoff = Math.min(backoff * 2, MAX_BACKOFF_MS) - } - } catch { - if (cancelled) return - backoff = Math.min(backoff * 2, MAX_BACKOFF_MS) - } finally { - controller = null - if (!cancelled) schedule() - } - } - - const onVisibility = () => { - if (document.visibilityState === 'visible') { - fetchOnce() - } else { - clearTimeout(timer) - controller?.abort() - } - } - - fetchOnce() - document.addEventListener('visibilitychange', onVisibility) - return () => { - cancelled = true - clearTimeout(timer) - controller?.abort() - document.removeEventListener('visibilitychange', onVisibility) - } - }, [enabled]) - - return stats -} diff --git a/lib/workflowQualification.mjs b/lib/workflowQualification.mjs index ea8fd5ce..ae99fc7e 100644 --- a/lib/workflowQualification.mjs +++ b/lib/workflowQualification.mjs @@ -100,6 +100,31 @@ export function scoreWorkflowQualification(form) { return { score, tier: 'community' } } +export const QUALIFICATION_SALES_TASK_SCHEMA = + 'openadapt.qualification-sales-task/v1' + +export function buildQualificationSalesTask({ + id, + qualification, + sourceRoute, + submittedAt, +}) { + if (!id || !qualification?.tier || !Number.isInteger(qualification?.score)) { + throw new Error('A sales task requires an id and a scored qualification') + } + + return Object.freeze({ + salesTaskSchema: QUALIFICATION_SALES_TASK_SCHEMA, + salesTaskId: id, + salesTaskStatus: 'new', + sourceRoute: sourceRoute || '/qualify', + bookingState: 'not_booked', + submittedAt, + qualificationScore: String(qualification.score), + qualificationTier: qualification.tier, + }) +} + export const QUALIFICATION_TIERS = Object.freeze({ priority: { heading: 'This looks like a strong qualification candidate.', diff --git a/netlify.toml b/netlify.toml index 0b09980f..6cb0af28 100644 --- a/netlify.toml +++ b/netlify.toml @@ -37,3 +37,10 @@ Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), microphone=(), geolocation=()" Strict-Transport-Security = "max-age=31536000; includeSubDomains" + +# Each reference directory carries a versioned qualification identifier and an +# inventory of exact hashes. Its media and JSON files do not change in place. +[[headers]] + for = "/reference/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" diff --git a/pages/_app.js b/pages/_app.js index 8db1d428..d04d1d91 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -29,14 +29,11 @@ export default function MyApp({ Component, pageProps }) { return ( <> - {/* New pages must add their own with unique title, description, canonical, and og:* tags */} + {/* Page-specific title, description, canonical, Open Graph, and + Twitter fields belong to each page. Global defaults caused two + competing values on every non-home route. */} - OpenAdapt — Verified execution for UI-only work - - - - {/* Twitter Card */} - - diff --git a/pages/index.js b/pages/index.js index cabe368a..2f07f670 100644 --- a/pages/index.js +++ b/pages/index.js @@ -12,7 +12,6 @@ import ProductStatus from '@components/ProductStatus' import Qualification from '@components/Qualification' import Reveal from '@components/Reveal' import TrustSummary from '@components/TrustSummary' -import useRepositoryStats from 'hooks/useRepositoryStats' import { OPENADAPT_STATS_SNAPSHOT } from '../data/repositoryStats' import publishedRepositoryStats from '../utils/publishedRepositoryStats' @@ -122,15 +121,22 @@ export async function getStaticProps() { } export default function Home({ githubStats, hostedOffer }) { - // Home owns the one live repository-stats request so every social-proof - // consumer updates atomically. Footer polling is disabled on this route; - // other pages let their Footer own the same shared hook. - const currentGithubStats = useRepositoryStats(githubStats) + const currentGithubStats = githubStats || OPENADAPT_STATS_SNAPSHOT + const title = 'OpenAdapt — Verified execution for UI-only work' + const description = + 'Automate consequential UI-only work across browser, desktop, RDP, and Citrix. OpenAdapt verifies the business effect and halts on uncertainty.' return (
+ {title} + + + + + +