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
2 changes: 1 addition & 1 deletion public/site.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"start_url": "/",
"display": "browser",
"background_color": "#ffffff",
"theme_color": "#000000",
"theme_color": "#ffffff",
"icons": [
{
"src": "/logo.png",
Expand Down
60 changes: 60 additions & 0 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@
--ring: oklch(0.72 0.178 55);
--rule: oklch(0.145 0 0 / 0.07);
--rule-strong: oklch(0.145 0 0 / 0.14);
--surface-raised: oklch(0.975 0 0);
}

.dark {
Expand All @@ -185,6 +186,65 @@
--ring: oklch(0.72 0.178 55);
--rule: oklch(1 0 0 / 0.08);
--rule-strong: oklch(1 0 0 / 0.16);
--surface-raised: oklch(0.185 0 0);
}

/*
* The product screenshot.
*
* `shadow-sm` is a 3px blur. On an image rendered 1216px wide that is an order
* of magnitude too small, which is why the hero read as a sticker laid on the
* page rather than a window sitting above it. Every reference Mac-app site
* converged on the same shape instead: a big soft ambient drop, and no single
* blur anywhere.
*
* `drop-shadow` rather than `box-shadow` because the captures carry real
* transparent macOS squircle corners — verified, the shipped PNGs have an alpha
* channel — so the shadow follows the actual silhouette. A box-shadow would
* draw a rectangle behind a rounded image, and CSS `border-radius` is a
* circular arc that cannot reproduce a continuous corner anyway. That is also
* why the border and radius come off: the image already has the corners, and a
* 1px rule tracing a squircle with an arc is visibly wrong at the join.
*
* Warm-tinted rather than neutral black, so it sits in the page's own light
* instead of muddying it. Three stacked is the practical paint limit.
*/
.app-plate {
filter:
drop-shadow(0 1px 1px oklch(0.145 0.004 55 / 0.04))
drop-shadow(0 6px 8px oklch(0.145 0.004 55 / 0.05))
drop-shadow(0 24px 32px oklch(0.145 0.004 55 / 0.07));
}

.dark .app-plate {
filter:
drop-shadow(0 1px 1px oklch(0 0 0 / 0.3))
drop-shadow(0 8px 16px oklch(0 0 0 / 0.35))
drop-shadow(0 32px 48px oklch(0 0 0 / 0.3));
}

/*
* The second ground.
*
* Sixteen sections shared one background, so over roughly ten thousand pixels
* of scroll the only rhythm device was hairline density — a reader could not
* tell from peripheral vision whether they had moved from Architecture to
* Agents to Mobile. `--card`, `--secondary` and `--accent` were all declared
* and used zero times.
*
* Two grounds, not five. The discipline is the point: `raised` goes only to the
* sections that are already conceptually inset.
*
* This has to sit after `.dark`, because `[data-tone="raised"]` and `.dark` are
* both specificity (0,1,0) and source order breaks the tie. It only needs one
* rule because `--surface-raised` is itself theme-swapped above.
*
* Every foreground token was checked against this surface rather than assumed:
* the tightest is `--muted-foreground-subtle` at 4.61:1, which still clears AA.
*/
[data-tone="raised"] {
--background: var(--surface-raised);
background-color: var(--background);
}

@layer base {
Expand Down
86 changes: 84 additions & 2 deletions resources/js/components/landing/database-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { Plus } from 'lucide-react';
import gridData from '../../../data/database-grid.json';
import Container from '@/components/ui/container';
Expand Down Expand Up @@ -78,6 +78,7 @@ function TileBody({ database }: { database: DatabaseTile }) {

export default function DatabaseGrid() {
const [activeCategory, setActiveCategory] = useState('all');
const tileRefs = useRef<(HTMLAnchorElement | null)[]>([]);

const counts = useMemo(() => {
const tally: Record<string, number> = { all: databases.length };
Expand All @@ -98,6 +99,67 @@ export default function DatabaseGrid() {
[activeCategory],
);

/**
* Columns at each breakpoint, so up and down move a whole row rather than a
* single tile. Read off `COLS` so the two can never disagree.
*/
function columnsNow(): number {
if (typeof window === 'undefined') {
return COLS.lg ?? COLS.base;
}

const width = window.innerWidth;
if (width >= 1024) return COLS.lg ?? COLS.base;
if (width >= 768) return COLS.md ?? COLS.base;
if (width >= 640) return COLS.sm ?? COLS.base;

return COLS.base;
}

function handleGridKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
// `visible` tiles plus the "request a database" tile at the end.
const last = visible.length;
const columns = columnsNow();
const current = tileRefs.current.findIndex((el) => el === document.activeElement);

if (current === -1) {
return;
}

const moves: Record<string, number> = {
ArrowRight: 1,
ArrowLeft: -1,
ArrowDown: columns,
ArrowUp: -columns,
};

let next: number | null = null;

if (event.key in moves) {
next = current + moves[event.key];
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = last;
}

// Clamp rather than wrap: wrapping a two-dimensional grid on Left at the
// start of a row lands you at the end of the previous one, which reads
// as the focus jumping backwards for no reason.
if (next === null) {
return;
}

const clamped = Math.max(0, Math.min(last, next));

if (clamped === current) {
return;
}

event.preventDefault();
tileRefs.current[clamped]?.focus();
}

const visibleIndex = useMemo(() => {
const map = new Map<string, number>();
visible.forEach((database, i) => map.set(database.name, i));
Expand Down Expand Up @@ -160,7 +222,25 @@ export default function DatabaseGrid() {
</div>

<div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{/*
* Arrow-key traversal across the tiles. Up and down move
* a whole row, Home and End jump to the ends.
*
* Purely additive: every tile stays in the tab order.
* The usual composite-widget pattern would take them all
* out and leave one, but that needs `role="grid"` with
* `row` and `gridcell` children for assistive tech to
* understand what happened — and this is one flat CSS
* grid whose borders are computed by index, so row
* wrappers would break the layout. Adding the roles
* without the structure would describe a widget that is
* not there. Arrows are a shortcut for people who can
* see where focus went; nobody loses anything.
*/}
<div
onKeyDown={handleGridKeyDown}
className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
>
{databases.map((database) => {
const index = visibleIndex.get(database.name);

Expand All @@ -181,6 +261,7 @@ export default function DatabaseGrid() {
return (
<a
key={database.name}
ref={(el) => { tileRefs.current[index] = el; }}
href={database.href}
data-row
className={`${TILE_CLASS} ${TILE_HOVER} ${borders}`}
Expand All @@ -191,6 +272,7 @@ export default function DatabaseGrid() {
})}

<a
ref={(el) => { tileRefs.current[visible.length] = el; }}
href={REQUEST_DATABASE_HREF}
data-row
target="_blank"
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/landing/depth-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ const ITEMS: DepthItem[] = [
*/
export default function DepthGrid() {
return (
<SectionShell tier="reference" id="more" label="Depth" headline="Six more things" headlineMuted="you would expect to pay for.">
<SectionShell tier="reference"
tone="raised" id="more" label="Depth" headline="Six more things" headlineMuted="you would expect to pay for.">
<FullLine />
<Container>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
Expand Down
3 changes: 2 additions & 1 deletion resources/js/components/landing/faq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export default function FAQ({
const mid = Math.ceil(items.length / 2);

return (
<SectionShell tier="reference" id="faq" label="FAQ" headline={headline} headlineMuted={headlineMuted}>
<SectionShell tier="reference"
tone="raised" id="faq" label="FAQ" headline={headline} headlineMuted={headlineMuted}>
<FullLine />
<Container>
<div className="grid grid-cols-1 md:grid-cols-2">
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/landing/hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default function Hero({ githubStars, latestRelease }: Props) {
width={3024}
height={1720}
priority
className="w-full rounded-xl border border-rule shadow-sm"
className="app-plate w-full"
/>
</div>
</Container>
Expand Down
67 changes: 21 additions & 46 deletions resources/js/components/landing/pricing.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import Container from '@/components/ui/container';
import { AccentLine, FullLine } from '@/components/ui/full-line';
import DataTable from '@/components/ui/data-table';
import { FullLine } from '@/components/ui/full-line';
import { Ledger, LedgerRow } from '@/components/ui/ledger';
import SectionLabel from '@/components/ui/section-label';
import SectionShell from '@/components/ui/section-shell';

type BillingCycle = 'monthly' | 'yearly' | 'lifetime';

Expand Down Expand Up @@ -328,40 +329,14 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv
];

return (
<section id="pricing" aria-labelledby="pricing-heading" className="scroll-mt-20">
<div className="h-12 sm:h-16 lg:h-24" />

{/* Label */}
<Container>
<AccentLine />
<SectionLabel className="pl-4">
Pricing
</SectionLabel>
<FullLine />
</Container>

{/* Spacer */}
<div className="h-4" />

{/* Headline */}
<Container>
<FullLine />
<h2 id="pricing-heading" className="pl-4 text-3xl font-bold sm:text-4xl">
The app is free.
<br />
<span className="text-muted-foreground">The license funds it.</span>
</h2>
<FullLine />
</Container>
<div className="h-2" />
<Container>
<FullLine />
<p className="max-w-3xl pl-4 text-base text-muted-foreground">
Starter is per person. Team is per seat, from {teamMinSeats} seats. Yearly saves 33 percent.
Lifetime pays for itself against yearly in about two and a half years.
</p>
<FullLine />
</Container>
<SectionShell
id="pricing"
label="Pricing"
headline="The app is free."
headlineMuted="The license funds it."
lede={`Starter is per person. Team is per seat, from ${teamMinSeats} seats. Yearly saves 33 percent. Lifetime pays for itself against yearly in about two and a half years.`}
tone="raised"
>

{/* What a license actually buys. Stated before the prices, on purpose. */}
<div className="h-4" />
Expand Down Expand Up @@ -555,22 +530,22 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv
*
* Availability is now real text, hidden visually.
*/}
<table className="w-full min-w-[500px] border-collapse">
<caption className="sr-only">
What each plan includes, compared across Free, Starter and Team.
</caption>
<DataTable
className="min-w-[500px]"
caption="What each plan includes, compared across Free, Starter and Team."
>
<thead>
<tr>
<th scope="col" className="p-4 text-left text-sm font-medium text-muted-foreground sm:p-5">
Feature
</th>
<th scope="col" className="border-l border-rule p-4 text-center text-sm font-medium text-muted-foreground sm:p-5">
<th scope="col" className="border-l border-rule-strong p-4 text-center text-sm font-medium text-muted-foreground sm:p-5">
Free
</th>
<th scope="col" className="border-l border-rule bg-primary/5 p-4 text-center text-sm font-bold text-primary-strong sm:p-5">
<th scope="col" className="border-l border-rule-strong bg-primary/5 p-4 text-center text-sm font-bold text-primary-strong sm:p-5">
Starter
</th>
<th scope="col" className="border-l border-rule p-4 text-center text-sm font-medium text-muted-foreground sm:p-5">
<th scope="col" className="border-l border-rule-strong p-4 text-center text-sm font-medium text-muted-foreground sm:p-5">
Team
</th>
</tr>
Expand All @@ -587,7 +562,7 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv
return (
<td
key={tier}
className={`border-l border-rule p-4 text-center sm:p-5 ${
className={`border-l border-rule-strong p-4 text-center sm:p-5 ${
tier === 'pro' ? 'bg-primary/5' : ''
}`}
>
Expand All @@ -614,10 +589,10 @@ export default function Pricing({ paymentProvider, teamMinSeats }: { paymentProv
</tr>
))}
</tbody>
</table>
</DataTable>
</div>
</Container>
<FullLine />
</section>
</SectionShell>
);
}
7 changes: 4 additions & 3 deletions resources/js/components/landing/safety.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
import Container from '@/components/ui/container';
import DataTable, { TABLE_ROW_RULE } from '@/components/ui/data-table';
import { FullLine } from '@/components/ui/full-line';
import { cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell';
import SectionShell from '@/components/ui/section-shell';
Expand Down Expand Up @@ -129,6 +130,7 @@ export default function Safety() {

return (
<SectionShell
tone="raised"
id="safety"
label="Safe Mode"
headline="Six levels between you and production."
Expand Down Expand Up @@ -157,8 +159,7 @@ export default function Safety() {
* its 200vw bleed would add scroll width in here.
*/}
<div className="overflow-x-auto" tabIndex={0} role="region" aria-label="Safe Mode levels">
<table className="w-full min-w-[38rem] border-collapse">
<caption className="sr-only">Safe Mode levels and their behaviour</caption>
<DataTable className="min-w-[38rem]" caption="Safe Mode levels and their behaviour">
<thead>
<tr className="border-b border-rule">
{COLUMNS.map((column) => (
Expand Down Expand Up @@ -196,7 +197,7 @@ export default function Safety() {
</tr>
))}
</tbody>
</table>
</DataTable>
</div>
</Container>
<FullLine />
Expand Down
Loading
Loading