From 00b16d050ac83de43ec29ec5aa9d99f46ac8bad4 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 22 Oct 2026 16:03:07 -0300 Subject: [PATCH] landing: still once it has shown it, on the brand's tokens, nothing moving under the reader Measured on the built site (Playwright, Lighthouse 12.8, axe 4.14): - The page moved under a phone reader: CLS 0.32. Plex was found late, through the stylesheet, and swapped in after the first paint (0.23); the terminal grew as it typed and the steps' list changed height as each claim showed (0.09). The faces the first screen sets are preloaded, the terminal has a fixed height, and every claim is always shown. CLS is 0.03 on a phone and 0 by Lighthouse. - The terminal looped forever with no way to stop it (WCAG 2.2.2). It plays once from the step picked and stops on the whole session; Skip ends it, Replay starts it again, focus moves between them. A screen reader reads the whole session from an sr-only copy rather than a half typed one. Reduced motion and no scripts show the session whole. - Every loop now runs once or a few times: the title's light, the dot, the running layer's dashes and glow, the card's ring. The hero's drift and the last section's turning mesh are gone: they only decorated. - Colours and corners are the brand's: the terminal and the last section are .spin-dark (spin-stack/brand#9) instead of eleven hex values, one a violet the palette does not have; radii are the palette's 4/8/12, not 6/14/16/18/24; durations and easing are --spin-duration-*/--spin-ease. The title's gradient on the navy is cyan and mint, not the blue the brand keeps off text there; the numbers on white are the accent and the deep blue (the cyan was 2:1). - Install on AWS is the primary action, in the hero and at the end. - The straight answers are cards with an icon each (Answers.astro); the list is still the page's Markdown and its twin is unchanged. - The glass header was never glass in Chrome: the minifier kept only -webkit-backdrop-filter. It is unprefixed now. - axe found the proof links under 24 px and the release and commit under AA in dark; Lighthouse found the logo's alt repeating the name beside it. All fixed: axe clean at WCAG 2.2 AA in both themes at 1440 and 390 px, Lighthouse mobile 99/100/100/100, desktop 100. Co-Authored-By: Claude Opus 5.5 --- astro.config.mjs | 4 +- src/components/Answers.astro | 61 +++++++ src/components/BentoCard.astro | 14 +- src/components/Cta.astro | 36 ++-- src/components/Head.astro | 8 + src/components/Hero.astro | 21 +-- src/components/Icon.astro | 22 +-- src/components/LayerStack.astro | 10 +- src/components/Stats.astro | 9 +- src/components/Story.astro | 281 +++++++++++++++++--------------- src/content/docs/index.mdx | 13 +- src/lib/icons.ts | 23 +++ src/lib/pages.ts | 1 + src/styles/brand-tokens.css | 7 +- src/styles/custom.css | 27 +-- 15 files changed, 316 insertions(+), 221 deletions(-) create mode 100644 src/components/Answers.astro create mode 100644 src/lib/icons.ts diff --git a/astro.config.mjs b/astro.config.mjs index 66249c3..8681929 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -23,8 +23,8 @@ export default defineConfig({ description: 'Self-hosted sandboxes in your own AWS account: a virtual machine per workspace, snapshots with memory, rollback, and a diff of what changed.', // The organization's own mark (spin-stack/brand): the tile on its navy in dark, and clear in light, - // where the tile would read as a sticker. - logo: { dark: './src/assets/logo.png', light: './src/assets/logo-light.png', alt: 'Spinbox' }, + // where the tile would read as a sticker. No alt: the name is written beside it. + logo: { dark: './src/assets/logo.png', light: './src/assets/logo-light.png', alt: '' }, favicon: '/favicon.png', social: [{ icon: 'github', label: 'GitHub', href: 'https://github.com/spin-stack' }], customCss: ['./src/styles/brand-tokens.css', './src/styles/brand-starlight.css', './src/styles/custom.css'], diff --git a/src/components/Answers.astro b/src/components/Answers.astro new file mode 100644 index 0000000..6d1fd7b --- /dev/null +++ b/src/components/Answers.astro @@ -0,0 +1,61 @@ +--- +// The straight answers - each a limit row of the status page - as a row of cards, an icon on +// each. The list stays the page's Markdown, so the twin is the same list (src/lib/pages.ts drops +// the tag); an icon per answer, in order, and a build that fails when the two counts differ. +import { iconSVG, type IconName } from '../lib/icons'; + +interface Props { + icons: IconName[]; +} +const { icons } = Astro.props; +const html = await Astro.slots.render('default'); +const items = html.match(/
  • /g)?.length ?? 0; +if (items !== icons.length) throw new Error(`: ${items} answers and ${icons.length} icons`); +let n = 0; +const drawn = html.replace(/
  • /g, () => `
  • ${iconSVG(icons[n++])}`); +--- + +
    + + diff --git a/src/components/BentoCard.astro b/src/components/BentoCard.astro index b85ce0f..d46db19 100644 --- a/src/components/BentoCard.astro +++ b/src/components/BentoCard.astro @@ -53,16 +53,16 @@ const proof = kind === 'e2e' ? `e2e · ${what}` : kind === 'cli' ? `spin ${what} flex-direction: column; gap: 0.6rem; padding: 1.4rem 1.4rem 1.2rem; - border-radius: 18px; + border-radius: var(--spin-radius-lg); text-decoration: none; color: var(--sl-color-gray-3); background: color-mix(in srgb, var(--sl-color-gray-6) 70%, transparent); border: 1px solid var(--sl-color-hairline-light); overflow: hidden; transition: - transform 300ms cubic-bezier(0.2, 0.7, 0.2, 1), - border-color 300ms, - box-shadow 300ms; + transform var(--spin-duration) var(--spin-ease), + border-color var(--spin-duration) var(--spin-ease), + box-shadow var(--spin-duration) var(--spin-ease); } .card:hover, .card:focus-visible { @@ -86,7 +86,7 @@ const proof = kind === 'e2e' ? `e2e · ${what}` : kind === 'cli' ? `spin ${what} place-items: center; width: 2.6rem; height: 2.6rem; - border-radius: 12px; + border-radius: var(--spin-radius); color: var(--spin-label); background: linear-gradient(135deg, color-mix(in srgb, var(--spin-brand-cyan) 18%, transparent), color-mix(in srgb, var(--spin-brand-blue) 18%, transparent)); border: 1px solid color-mix(in srgb, var(--spin-brand-cyan) 28%, transparent); @@ -122,7 +122,7 @@ const proof = kind === 'e2e' ? `e2e · ${what}` : kind === 'cli' ? `spin ${what} stroke-width: 1.5; } .ring-label { - font-size: 10px; + font-size: 11px; fill: var(--sl-color-gray-3); letter-spacing: 0.05em; } @@ -135,7 +135,7 @@ const proof = kind === 'e2e' ? `e2e · ${what}` : kind === 'cli' ? `spin ${what} } @media (prefers-reduced-motion: no-preference) { .r3 { - animation: ring-breathe 3.2s ease-in-out infinite alternate; + animation: ring-breathe 1.6s ease-in-out 2 alternate; } @keyframes ring-breathe { to { diff --git a/src/components/Cta.astro b/src/components/Cta.astro index 2da2c2b..84aaa6a 100644 --- a/src/components/Cta.astro +++ b/src/components/Cta.astro @@ -10,7 +10,7 @@ interface Props { const { title, links } = Astro.props; --- -
    +

    {title}

    @@ -32,17 +32,17 @@ const { title, links } = Astro.props; overflow: hidden; margin-block: 4rem 1rem; padding: clamp(2.5rem, 2rem + 3vw, 4.5rem) clamp(1.5rem, 1rem + 3vw, 4rem); - border-radius: 24px; - background: #0b1120; - color: #e6edf6; + border-radius: var(--spin-radius-lg); + background: var(--spin-page); + color: var(--spin-text); text-align: center; - border: 1px solid #2a3a58; + border: 1px solid var(--spin-border); } .mesh { position: absolute; inset: -40%; z-index: -1; - background: conic-gradient(from 180deg at 50% 50%, #0c3c84, #2f6be3, #27bfe8, #5fe3d6, #0c3c84); + background: conic-gradient(from 180deg at 50% 50%, var(--spin-brand-deep), var(--spin-brand-blue), var(--spin-brand-cyan), var(--spin-brand-mint), var(--spin-brand-deep)); filter: blur(70px); opacity: 0.55; } @@ -53,12 +53,12 @@ const { title, links } = Astro.props; font-size: clamp(1.8rem, 1.2rem + 2.4vw, 3rem); line-height: 1.1; letter-spacing: -0.035em; - color: #fff; + color: var(--spin-brand-frost); } p { margin: 1rem auto 2rem; max-width: 40rem; - color: #c8d3e4; + color: var(--spin-text); font-size: var(--sl-text-lg); } .links { @@ -68,22 +68,12 @@ const { title, links } = Astro.props; gap: 1rem; } .links :global(.sl-link-button.secondary) { - color: #fff; - border-color: rgb(255 255 255 / 0.4); + color: var(--spin-brand-frost); + border-color: var(--spin-border-strong); } .links :global(.sl-link-button.primary) { - background: #fff; - border-color: #fff; - color: #0b1120; - } - @media (prefers-reduced-motion: no-preference) { - .mesh { - animation: cta-spin 22s linear infinite; - } - @keyframes cta-spin { - to { - transform: rotate(1turn); - } - } + background: var(--spin-brand-frost); + border-color: var(--spin-brand-frost); + color: var(--spin-page); } diff --git a/src/components/Head.astro b/src/components/Head.astro index d62852a..32ae8d0 100644 --- a/src/components/Head.astro +++ b/src/components/Head.astro @@ -4,9 +4,17 @@ // does not resolve. import Default from '@astrojs/starlight/components/Head.astro'; import { twinPath } from '../lib/twin'; +// The faces the first screen sets, fetched with the page: found late, through the stylesheet, a +// face swaps in after the first paint and the notice and the hero move with it (a layout shift of +// 0.23 on a phone, measured). +import sans400 from '@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-400-normal.woff2?url'; +import sans600 from '@fontsource/ibm-plex-sans/files/ibm-plex-sans-latin-600-normal.woff2?url'; +import mono400 from '@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2?url'; +import mono600 from '@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-600-normal.woff2?url'; const base = import.meta.env.BASE_URL.replace(/\/$/, ''); --- +{[sans400, sans600, mono400, mono600].map((href) => )} diff --git a/src/components/Hero.astro b/src/components/Hero.astro index 7b98f04..ae9847c 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -95,14 +95,15 @@ const rest = cut < 0 ? '' : title.slice(cut + 2); margin: 0; } .hl { - background: linear-gradient(100deg, var(--spin-brand-cyan), var(--spin-brand-blue) 55%, var(--spin-brand-mint)); + /* The mark's lights that may be text on the navy: cyan and mint, never its blue. */ + background: linear-gradient(100deg, var(--spin-brand-cyan), var(--spin-brand-mint) 55%, var(--spin-brand-cyan)); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; } :global([data-theme='light']) .hl { - background-image: linear-gradient(100deg, #0a6fa3, var(--spin-brand-blue) 55%, var(--spin-brand-deep)); + background-image: linear-gradient(100deg, var(--spin-accent), var(--spin-brand-deep) 55%, var(--spin-accent)); } .tagline { font-size: var(--sl-text-lg); @@ -138,7 +139,7 @@ const rest = cut < 0 ? '' : title.slice(cut + 2); } @media (prefers-reduced-motion: no-preference) { .copy > * { - animation: hero-rise 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation: hero-rise var(--spin-duration-draw) var(--spin-ease) both; } .copy > :nth-child(2) { animation-delay: 80ms; @@ -149,14 +150,13 @@ const rest = cut < 0 ? '' : title.slice(cut + 2); .copy > :nth-child(4) { animation-delay: 240ms; } + /* Once each (palette.toml's [motion]): the light crosses the title, the dot pulses three + times, and both stay as they end. */ .hl { - animation: hero-sheen 8s ease-in-out infinite alternate; + animation: hero-sheen 2.4s var(--spin-ease) 400ms both; } .dot { - animation: hero-pulse 2.4s ease-out infinite; - } - .backdrop { - animation: hero-drift 18s ease-in-out infinite alternate; + animation: hero-pulse 1.6s ease-out 3; } @keyframes hero-rise { from { @@ -175,10 +175,5 @@ const rest = cut < 0 ? '' : title.slice(cut + 2); box-shadow: 0 0 0 0.6rem transparent; } } - @keyframes hero-drift { - to { - transform: translate3d(-2%, 3%, 0) scale(1.04); - } - } } diff --git a/src/components/Icon.astro b/src/components/Icon.astro index 2cce070..a259a10 100644 --- a/src/components/Icon.astro +++ b/src/components/Icon.astro @@ -1,23 +1,11 @@ --- -// The landing page's few icons, drawn here (24-unit strokes in the current colour) so the site -// loads nothing from anyone else. -const paths = { - machine: 'M4 5h16v10H4zM8 19h8M12 15v4M7 9l2 2-2 2M11 13h4', - memory: 'M6 4h12v16H6zM9 8h6M9 12h6M9 16h3M3 8h3M3 12h3M3 16h3M18 8h3M18 12h3M18 16h3', - diff: 'M7 4v10M7 18v2M4 7h6M17 4v16M14 12h6M12 2v20', - shield: 'M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6zM9 12l2 2 4-4', - key: 'M14 10a4 4 0 1 0-1.2 2.8L20 20M17 17l2-2M15 15l1.5-1.5', - agent: 'M12 3v3M7 7h10a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3zM9 12h.01M15 12h.01M9 16h6', - cloud: 'M7 18a5 5 0 0 1-.6-10A6 6 0 0 1 18 9a4.5 4.5 0 0 1-.5 9zM12 11v5M10 14l2 2 2-2', - move: 'M4 12h16M16 8l4 4-4 4M8 8l-4 4 4 4', - moon: 'M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z', -} as const; +// One of the landing page's icons (src/lib/icons.ts). +import { iconSVG, type IconName } from '../lib/icons'; + interface Props { - name: keyof typeof paths; + name: IconName; } const { name } = Astro.props; --- - + diff --git a/src/components/LayerStack.astro b/src/components/LayerStack.astro index 90583bd..6479d51 100644 --- a/src/components/LayerStack.astro +++ b/src/components/LayerStack.astro @@ -90,7 +90,7 @@ const layers = [ machine writes; then what a diff and a restore do with them. */ @media (prefers-reduced-motion: no-preference) { .layer { - animation: ls-drop 650ms cubic-bezier(0.2, 0.8, 0.25, 1.15) both; + animation: ls-drop var(--spin-duration-draw) var(--spin-ease) both; animation-delay: calc(150ms + var(--i) * 180ms); } .verb .bracket { @@ -104,13 +104,13 @@ const layers = [ } .live { animation: - ls-write 1.6s linear infinite, - ls-fade 400ms ease-out both; + ls-write 1.6s linear 3, + ls-fade var(--spin-duration-slow) ease-out both; } .live-top { animation: - ls-write 1.6s linear infinite, - ls-glow 2.4s ease-in-out infinite alternate; + ls-write 1.6s linear 3, + ls-glow 2.4s ease-in-out 2 alternate; } @keyframes ls-drop { from { diff --git a/src/components/Stats.astro b/src/components/Stats.astro index b532ad3..26765e0 100644 --- a/src/components/Stats.astro +++ b/src/components/Stats.astro @@ -61,7 +61,7 @@ const stats = [ grid-template-columns: repeat(2, 1fr); gap: 1px; margin-block: 1.5rem 1rem; - border-radius: 18px; + border-radius: var(--spin-radius-lg); overflow: hidden; background: var(--sl-color-hairline-light); border: 1px solid var(--sl-color-hairline-light); @@ -85,11 +85,16 @@ const stats = [ line-height: 1; letter-spacing: -0.04em; font-variant-numeric: tabular-nums; - background: linear-gradient(120deg, var(--spin-brand-cyan), var(--spin-brand-blue)); + /* Cyan to mint on the navy; on white the accent to the mark's base, which hold AA where the + mark's cyan is 2:1. */ + background: linear-gradient(120deg, var(--spin-brand-cyan), var(--spin-brand-mint)); -webkit-background-clip: text; background-clip: text; color: transparent; } + :global([data-theme='light']) .n { + background-image: linear-gradient(120deg, var(--spin-accent), var(--spin-brand-deep)); + } .label { color: var(--sl-color-white); font-weight: 600; diff --git a/src/components/Story.astro b/src/components/Story.astro index 80d8377..8a81d97 100644 --- a/src/components/Story.astro +++ b/src/components/Story.astro @@ -38,6 +38,7 @@ const screen = steps '', ) .join(''); +const plain = esc(steps.map((s) => [...s.commands.map((c) => `$ ${c}`), ...stepOutput(s)].join('\n')).join('\n\n')); ---
    @@ -61,14 +62,18 @@ const screen = steps )) } -
    +
    - demo — spin - + + +
    -
    + +