Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/components/StoreBadge/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,25 +286,34 @@ export function DirectDownloadButton({
* labels its downloads, and the visitor this page is for wants to know that
* clicking gets them the app, not what a .dmg is.
*/
// What the block calls each architecture, where a platform offers more than
// one and the block has to say which build it is.
const ARCH_LABEL = { x64: 'x86_64', arm64: 'ARM64' } as const;

export function DirectDownloadBlock({
platform,
arch,
size,
variant = 'primary',
}: {
platform: DirectPlatform;
// Set only where the platform ships more than one build (Linux); the block
// then names the architecture in place of who the build covers.
arch?: 'x64' | 'arm64';
// Unknown until the release fetch returns; the block reads fine without it.
size?: string;
variant?: ButtonVariant;
}): ReactNode {
const { mark, name, platform: releasePlatform, format, covers } = DIRECT_PLATFORM[platform];
const path = useInstallerUrl()(releasePlatform);
const { mark, name, platform: releasePlatform, format, covers: platformCovers } = DIRECT_PLATFORM[platform];
const path = useInstallerUrl()(releasePlatform, arch);
const covers = arch ? ARCH_LABEL[arch] : platformCovers;

return (
<a
href={path}
download
className={variantClass(styles.directBlock, variant)}
aria-label={`Download Smartloop Studio for ${name} (${size ? `${format}, ${size}` : format})`}
aria-label={`Download Smartloop Studio for ${name}${arch ? ` ${ARCH_LABEL[arch]}` : ''} (${size ? `${format}, ${size}` : format})`}
>
<Mark name={mark} />
<span className={styles.directBlockText}>
Expand Down
13 changes: 7 additions & 6 deletions src/components/StoreBadge/styles.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,7 @@
}

.directCta:hover,
.directCta:focus-visible,
.directBlock:hover,
.directBlock:focus-visible {
.directCta:focus-visible {
--dl-plate: var(--sl-white);
--dl-ink: var(--sl-ink);
color: var(--dl-ink);
Expand All @@ -118,8 +116,9 @@
}

/* On the download page the outline is the card's own border colour, so the
button sits in its card as part of it; it turns solid only on hover. The
light value matches the light theme's override of the card border in
button sits in its card as part of it. It never fills: on hover the outline
turns the brand pink; the card itself stays still. The light
value matches the light theme's override of the card border in
pages/index.module.css. */
.directBlock {
--dl-edge: var(--sl-line);
Expand All @@ -133,7 +132,9 @@
.directBlock:focus-visible,
[data-theme='light'] .directBlock:hover,
[data-theme='light'] .directBlock:focus-visible {
--dl-edge: var(--sl-white);
--dl-edge: var(--sl-accent);
text-decoration: none;
transform: translateY(-1px);
}

/* The home page hero's button is the one exception: the solid brand pink, for
Expand Down
13 changes: 9 additions & 4 deletions src/hooks/useLatestRelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,22 @@ export default function useLatestRelease(): LatestRelease {
: { status: 'unavailable', release: null };
}

/** First artifact matching a platform/kind pair, or null. */
/**
* The artifact for a platform/kind pair, or null. With `arch`, only that
* build; without it, x64 leads where there is one, as the API's download
* redirect does.
*/
export function findArtifact(
release: Release | null,
platform: ArtifactPlatform,
kind: ArtifactKind,
arch?: ArtifactArch,
): ReleaseArtifact | null {
if (!release) return null;
return (
release.artifacts.find((a) => a.platform === platform && a.kind === kind) ??
null
const matches = release.artifacts.filter(
(a) => a.platform === platform && a.kind === kind && (!arch || a.arch === arch),
);
return matches.find((a) => a.arch === 'x64') ?? matches[0] ?? null;
}

/**
Expand Down
175 changes: 130 additions & 45 deletions src/pages/download.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type ReactNode } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import Head from '@docusaurus/Head';
import Header from '../components/Header';
import Footer from '../components/Footer';
Expand All @@ -7,6 +7,7 @@ import useLatestRelease, {
formatSize,
type ArtifactKind,
type ArtifactPlatform,
type Release,
} from '../hooks/useLatestRelease';
import {
DirectDownloadBlock,
Expand Down Expand Up @@ -54,6 +55,8 @@ type StudioCard = {
// payloads and must never be offered as a download link.
platform: ArtifactPlatform;
kind: ArtifactKind;
// One download per architecture where the platform ships more than one.
arches?: ('x64' | 'arm64')[];
};

const studioCards: StudioCard[] = [
Expand Down Expand Up @@ -90,20 +93,141 @@ const studioCards: StudioCard[] = [
{
key: 'linux',
title: 'Linux',
meta: 'Linux · x86_64',
meta: 'Linux · x86_64 · ARM64',
icon: <LinuxIcon />,
minimum: [
'Linux x86_64 — Debian, Ubuntu, or a derivative',
'Linux x86_64 or ARM64 — Debian, Ubuntu, or a derivative',
'Vulkan-capable GPU — NVIDIA, AMD, or Intel, including integrated graphics',
'16 GB system RAM',
'10 GB free disk space',
],
recommended: ['More system RAM is better — 24 GB or above', 'A discrete NVIDIA GPU (RTX 40-series or newer, 8 GB+ VRAM)'],
platform: 'linux',
kind: 'deb',
arches: ['x64', 'arm64'],
},
];

type Arch = 'x64' | 'arm64';

const ARCH_NAME: Record<Arch, string> = { x64: 'x86_64', arm64: 'ARM64' };

/**
* The machine's architecture, where the browser says. Chromium reports it
* through User-Agent Client Hints; other browsers sometimes name it in the
* user agent (aarch64 / arm64 on Linux). Null off Linux, or when there is no
* telling, and the switch stays on x86_64.
*/
async function detectArch(): Promise<Arch | null> {
const nav = navigator as Navigator & {
userAgentData?: {
platform?: string;
getHighEntropyValues(hints: string[]): Promise<{ architecture?: string }>;
};
};
// Only a Linux machine's own architecture says which Linux build it wants;
// on a Mac or PC the download is for some other box, so leave the default.
const platform = nav.userAgentData?.platform ?? navigator.userAgent;
if (!/linux/i.test(platform) || /android/i.test(navigator.userAgent)) return null;
try {
const hints = await nav.userAgentData?.getHighEntropyValues(['architecture']);
if (hints?.architecture === 'arm') return 'arm64';
if (hints?.architecture === 'x86') return 'x64';
} catch {
// Hints refused; fall back to the user agent.
}
if (/aarch64|arm64|armv8/i.test(navigator.userAgent)) return 'arm64';
if (/x86_64|amd64|x64/i.test(navigator.userAgent)) return 'x64';
return null;
}

/**
* The build a multi-architecture card offers. It starts on the visitor's own
* architecture when the browser reports it, so most people never touch the
* switch.
*/
function useArch(arches: Arch[] | undefined): [Arch | undefined, (a: Arch) => void] {
const [arch, setArch] = useState<Arch | undefined>(arches?.[0]);

useEffect(() => {
if (!arches) return;
let live = true;
detectArch().then((found) => {
if (live && found && arches.includes(found)) setArch(found);
});
return () => {
live = false;
};
}, []);

return [arch, setArch];
}

/**
* A platform's card. Where the platform ships more than one architecture, the
* switch takes the place of the line under its name, and the one button at
* the foot downloads whichever build is picked.
*/
function DownloadCard({ card, release }: { card: StudioCard; release: Release | null }): ReactNode {
const [arch, setArch] = useArch(card.arches);
const artifact = findArtifact(release, card.platform, card.kind, arch);

return (
// The download link carries its own click, so the card itself is a plain
// container rather than one big link, and does not light up on hover.
<div className={styles.downloadCard}>
<div className={styles.downloadCardHeader}>
<div className={styles.downloadCardIcon}>{card.icon}</div>
<div className={styles.downloadCardHeaderText}>
<p className={styles.downloadCardTitle}>{card.title}</p>
{card.arches && arch ? (
<div className={`${styles.downloadOsTabs} ${styles.archSwitch}`} role="radiogroup" aria-label="Architecture">
{card.arches.map((a) => (
<button
key={a}
type="button"
role="radio"
aria-checked={a === arch}
className={`${styles.downloadOsTab} ${a === arch ? styles.downloadOsTabActive : ''}`}
onClick={() => setArch(a)}
>
{ARCH_NAME[a]}
</button>
))}
</div>
) : (
<p className={styles.downloadCardMeta}>{card.meta}</p>
)}
</div>
</div>
<p className={styles.downloadReqLabel}>Minimum</p>
<ul className={styles.downloadReqList}>
{card.minimum.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
<p className={styles.downloadRecLabel}>Recommended</p>
<ul className={styles.downloadRecList}>
{card.recommended.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
<div className={styles.downloadCardFoot}>
{/* The block links to the API's tracked redirect, which resolves the
latest build itself; the release fetch only adds the size. Every
card gets the same plain button, so the visitor's own platform is
not singled out. */}
<DirectDownloadBlock
platform={card.key}
arch={arch}
size={artifact ? formatSize(artifact.size) : undefined}
variant="secondary"
/>
</div>
</div>
);
}

export default function Download(): ReactNode {
const { status, release } = useLatestRelease();

Expand Down Expand Up @@ -138,48 +262,9 @@ export default function Download(): ReactNode {
{/* Studio downloads */}
<section className="px-6 pt-4 pb-10 max-w-5xl mx-auto w-full">
<div className={styles.downloadGrid}>
{studioCards.map((card) => {
const artifact = findArtifact(release, card.platform, card.kind);
// Every card gets the plain button; hover is what highlights
// one, so the visitor's own platform is not singled out.
const variant = 'secondary';

return (
// The badge and the direct link each carry their own click, so
// the card itself is a plain container rather than one big link.
<div key={card.key} className={styles.downloadCard}>
<div className={styles.downloadCardHeader}>
<div className={styles.downloadCardIcon}>{card.icon}</div>
<div className={styles.downloadCardHeaderText}>
<p className={styles.downloadCardTitle}>{card.title}</p>
<p className={styles.downloadCardMeta}>{card.meta}</p>
</div>
</div>
<p className={styles.downloadReqLabel}>Minimum</p>
<ul className={styles.downloadReqList}>
{card.minimum.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
<p className={styles.downloadRecLabel}>Recommended</p>
<ul className={styles.downloadRecList}>
{card.recommended.map((req, i) => (
<li key={i}>{req}</li>
))}
</ul>
<div className={styles.downloadCardFoot}>
{/* The block links to the API's tracked redirect,
which resolves the latest build itself; the release
fetch only adds the size. */}
<DirectDownloadBlock
platform={card.key}
size={artifact ? formatSize(artifact.size) : undefined}
variant={variant}
/>
</div>
</div>
);
})}
{studioCards.map((card) => (
<DownloadCard key={card.key} card={card} release={release} />
))}
</div>
</section>

Expand Down
42 changes: 17 additions & 25 deletions src/pages/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -746,13 +746,6 @@ details[open] .faqQuestion::after {
flex-direction: column;
gap: 14px;
text-decoration: none;
transition: border-color 0.2s, background 0.2s;
}

.downloadCard:hover {
border-color: var(--sl-accent);
background: var(--sl-panel);
text-decoration: none;
}

.downloadCardDisabled {
Expand Down Expand Up @@ -1027,6 +1020,23 @@ details[open] .faqQuestion::after {
color: var(--sl-accent);
}

/* The architecture switch in the header of a card whose platform ships more
than one build, in place of the line under its name. Rounded like the
download button, and in the builds' own spelling. */
.archSwitch {
margin-top: 4px;
padding: 2px;
border-radius: var(--r-sm, 10px);
}

.archSwitch .downloadOsTab {
padding: 3px 10px;
border-radius: calc(var(--r-sm, 10px) - 2px);
text-transform: none;
letter-spacing: 0.02em;
font-family: var(--sl-mono);
}

/* ===================== Pricing ===================== */
.pricingGrid {
display: grid;
Expand Down Expand Up @@ -1375,10 +1385,6 @@ details[open] .faqQuestion::after {
border-color: rgba(204, 204, 204, 0.5);
}

[data-theme='light'] .downloadCard:hover {
background: #ededed;
}

[data-theme='light'] .downloadCardTitle {
color: #0a0a0a;
}
Expand All @@ -1397,11 +1403,6 @@ details[open] .faqQuestion::after {
border-color: rgba(204, 204, 204, 0.5);
}

[data-theme='light'] .downloadCard:hover {
border-color: var(--sl-accent);
background: #ededed;
}

[data-theme='light'] .downloadCardAction {
background: rgba(0, 0, 0, 0.04);
color: #0a0a0a;
Expand Down Expand Up @@ -2828,15 +2829,6 @@ details[open] .faqQuestion::after {
box-shadow: var(--shadow-soft);
}

.downloadCard:hover {
border-color: rgba(236, 72, 153, 0.35);
transform: translateY(-2px);
}

.downloadCard {
transition: border-color 0.2s, transform 0.2s;
}

.planCardFeatured {
border-color: rgba(236, 72, 153, 0.45);
box-shadow: var(--shadow-soft), 0 0 60px -10px var(--sl-accent-wash);
Expand Down
Loading
Loading