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
18 changes: 18 additions & 0 deletions src/data/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,21 @@ export const FREE_FEATURES: readonly string[] = [
'Managing groups',
'Find My',
] as const;

/** macOS releases the Swift server runs on.
*
* Names and numbers come from the server's own `docs/MACOS_COMPATIBILITY.md`.
* The floor is 14: `Package.swift` declares `.macOS(.v14)`, so there is nothing
* below it to offer. Note the numbering jumps 15 -> 26; Apple moved to
* year-based versions, and 16 through 25 do not exist.
*/
export interface MacOSRelease {
version: number;
name: string;
}

export const MACOS_RELEASES: readonly MacOSRelease[] = [
{ version: 14, name: 'Sonoma' },
{ version: 15, name: 'Sequoia' },
{ version: 26, name: 'Tahoe' },
] as const;
123 changes: 121 additions & 2 deletions src/pages/pricing/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
*/
import BaseLayout from '@layouts/BaseLayout.astro';
import PageHeader from '@components/PageHeader.astro';
import { PLANS, PRO_FEATURES, FREE_FEATURES } from '@data/pricing';
import { PLANS, PRO_FEATURES, FREE_FEATURES, MACOS_RELEASES } from '@data/pricing';
import { LINKS } from '@data/site';

// Default the picker to the newest release, so the list opens on the full
// feature set. Derived from the data rather than hardcoded, so adding macOS 27
// to MACOS_RELEASES moves the default automatically.
const newestMacOS = MACOS_RELEASES.reduce((a, b) => (b.version > a.version ? b : a));
---

<BaseLayout
Expand Down Expand Up @@ -69,17 +74,81 @@ import { LINKS } from '@data/site';
minimum macOS version, shown alongside it.
</p>

<div class="mt-6 flex flex-wrap items-center gap-3">
<label for="macos-filter" class="text-sm font-medium text-strong">
Your server Mac runs
</label>
{/*
appearance-none plus our own chevron. The native arrow sits inside the
right padding, and on a rounded-full pill the 18.5px corner radius
curves in over it, so it reads as crammed against the edge. Drawing it
ourselves also makes it match the FAQ accordion's chevron and follow
the theme, which the native control does not.
*/}
<div class="relative inline-flex">
<select
id="macos-filter"
class="appearance-none rounded-full border border-line bg-page py-2 pl-4 pr-10
text-sm font-medium text-strong
focus-visible:border-accent focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-accent"
>
{
MACOS_RELEASES.map((release) => (
<option value={release.version} selected={release.version === newestMacOS.version}>
{release.name} ({release.version})
</option>
))
}
</select>
<svg
class="pointer-events-none absolute right-3.5 top-1/2 size-4 -translate-y-1/2 text-muted"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path d="M5 7.5 10 12.5 15 7.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</div>
<p id="macos-summary" role="status" aria-live="polite" class="text-sm text-muted">
{PRO_FEATURES.filter((f) => f.macos <= newestMacOS.version).length} of {
PRO_FEATURES.length
} Pro features run on {newestMacOS.name} ({newestMacOS.version}).
</p>
</div>

<ul class="mt-6 grid gap-4 sm:grid-cols-2">
{
PRO_FEATURES.map((feature) => (
<li class="rounded-card border border-line bg-raised p-5">
<li
data-feature
data-macos={feature.macos}
data-state={feature.macos <= newestMacOS.version ? 'included' : 'excluded'}
class="rounded-card border border-line bg-raised p-5 transition
data-[state=included]:border-accent data-[state=included]:bg-accent-solid/5
data-[state=excluded]:opacity-55"
>
<div class="flex items-start justify-between gap-3">
<h3 class="font-semibold text-strong">{feature.title}</h3>
<span class="shrink-0 rounded-full border border-line px-2 py-0.5 text-xs font-medium text-muted">
macOS {feature.macos}+
</span>
</div>
<p class="mt-2 text-sm text-muted">{feature.summary}</p>
{/* Text, not just colour: the state has to survive greyscale and a
screen reader. Rendered server-side for the default selection,
so this is correct before the script runs and without it. */}
<p
data-verdict
class:list={[
'mt-3 text-xs font-semibold',
feature.macos <= newestMacOS.version ? 'text-accent' : 'text-muted',
]}
>
{feature.macos <= newestMacOS.version ? '✓ Included' : `Needs macOS ${feature.macos}`}
</p>
</li>
))
}
Expand Down Expand Up @@ -123,3 +192,53 @@ import { LINKS } from '@data/site';
</div>
</div>
</BaseLayout>

<script>
// Highlights which Pro features a given macOS release can actually run.
//
// The rule is cumulative: a release includes everything introduced at or below
// its own version, so Tahoe (26) gets the Sequoia and Sonoma features too.
// That is a `<=` on the major version and nothing more -- the catalogue's
// minimums are the only input.
const select = document.getElementById('macos-filter') as HTMLSelectElement | null;
const summary = document.getElementById('macos-summary');
const cards = [...document.querySelectorAll<HTMLElement>('[data-feature]')];

if (select && cards.length) {
const names = new Map<number, string>(
[...select.options]
.filter((option) => option.value !== '0')
.map((option) => [Number(option.value), option.textContent?.trim() ?? ''])
);

const apply = () => {
const chosen = Number(select.value);
if (!chosen) return;

let included = 0;
for (const card of cards) {
const needs = Number(card.dataset.macos);
const ok = needs <= chosen;
if (ok) included += 1;
card.dataset.state = ok ? 'included' : 'excluded';

const verdict = card.querySelector<HTMLElement>('[data-verdict]');
if (verdict) {
verdict.textContent = ok ? '✓ Included' : `Needs macOS ${needs}`;
verdict.className = ok
? 'mt-3 text-xs font-semibold text-accent'
: 'mt-3 text-xs font-semibold text-muted';
}
}

if (summary) {
const label = names.get(chosen) ?? String(chosen);
summary.textContent = `${included} of ${cards.length} Pro features run on ${label}.`;
}
};

select.addEventListener('change', apply);
// Browsers restore a previous selection on reload; reflect it immediately.
apply();
}
</script>
Loading