`.
+ *
+ * Open won. These pages carry three to eight questions, so there is nothing to
+ * collapse for, and an answer behind a disclosure is an answer a search engine
+ * ranks lower and a skimming reader never sees. The disclosure also gave the
+ * only affordance on the page that changed shape on click without saying so.
+ *
+ * Two columns above md when there is enough to split; one below, and one always
+ * when there are three or fewer.
+ */
+export default function FaqList({ items }: { items: FaqItem[] }) {
+ const split = items.length > 3;
+ const mid = Math.ceil(items.length / 2);
+
+ if (!split) {
+ return
;
+ }
+
+ return (
+
+
+
+
+ );
+}
diff --git a/resources/js/components/ui/glyph.tsx b/resources/js/components/ui/glyph.tsx
new file mode 100644
index 0000000..a1dfe03
--- /dev/null
+++ b/resources/js/components/ui/glyph.tsx
@@ -0,0 +1,87 @@
+import { cn } from '@/lib/utils';
+
+interface GlyphProps {
+ className?: string;
+}
+
+/**
+ * The Apple mark, for the "Download for Mac" buttons.
+ *
+ * It was inlined eight times in four shapes, and four of those copies carried
+ * no size class at all — inside a flex button, an SVG with no width collapses
+ * to whatever the browser guesses. Half of them were also missing
+ * `aria-hidden`, so a screen reader read an unlabelled graphic in the middle of
+ * a button that already says "Download for Mac".
+ *
+ * `size-4` is the default because that is what the buttons around it use; the
+ * hero passes `size-5` because its button is a rung larger.
+ */
+export function AppleGlyph({ className }: GlyphProps) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * The "included" tick.
+ *
+ * Three copies existed and they did not agree on colour: two used
+ * `--primary-strong`, one used bare `--primary` at 2.62:1 — the exact failure
+ * `SectionLabel` was created to stop, surviving in a glyph that is the sole
+ * carrier of meaning in a comparison column.
+ *
+ * `aria-hidden` on purpose. Wherever this appears the cell must also carry
+ * visually hidden text, because a tick with no text alternative announces as
+ * nothing and the reader is told the feature is absent.
+ */
+export function CheckGlyph({ className }: GlyphProps) {
+ return (
+
+
+
+ );
+}
+
+/** The "not included" cross. Same rule: it needs text beside it, not instead of it. */
+export function CrossGlyph({ className }: GlyphProps) {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Availability in a comparison cell: the glyph plus the word.
+ *
+ * This pair exists as one component because separating them is exactly how the
+ * bug keeps coming back. Both glyphs are `aria-hidden`, so a cell holding only
+ * one announces as empty — and an empty cell in a comparison table does not
+ * read as "no answer", it reads as "not included". The pricing table shipped
+ * that way, was fixed, and Compare then lost the same fix when its local
+ * wrappers were replaced.
+ */
+export function Availability({ included }: { included: boolean }) {
+ return (
+ <>
+
{included ? 'Included' : 'Not included'}
+ {included ?
:
}
+ >
+ );
+}
diff --git a/resources/js/components/ui/prose-block.tsx b/resources/js/components/ui/prose-block.tsx
new file mode 100644
index 0000000..1f09b66
--- /dev/null
+++ b/resources/js/components/ui/prose-block.tsx
@@ -0,0 +1,33 @@
+import { ReactNode } from 'react';
+import { FullLine } from '@/components/ui/full-line';
+
+/**
+ * A titled block of body copy, ruled off from the one above it.
+ *
+ * The legal pages are a stack of these. Privacy and Terms each declared a
+ * byte-identical local copy, which is two places for one rule to drift and two
+ * places to fix when it does.
+ *
+ * The heading is an h2 because these sit under the page's single h1.
+ */
+export function ProseBlock({ title, children }: { title: string; children: ReactNode }) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+/**
+ * The marker on a bulleted line inside a ProseBlock.
+ *
+ * `mt-1.5` aligns the dot to the cap height of the first line rather than to
+ * the line box, which is why it is not a list-style marker.
+ */
+export function Bullet() {
+ return
;
+}
diff --git a/resources/js/components/ui/prose-link.tsx b/resources/js/components/ui/prose-link.tsx
new file mode 100644
index 0000000..bba2761
--- /dev/null
+++ b/resources/js/components/ui/prose-link.tsx
@@ -0,0 +1,36 @@
+import { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+
+/** The one class list for a link inside body copy. */
+export const PROSE_LINK =
+ 'text-foreground underline underline-offset-4 transition-colors duration-(--dur-tap) ease-(--ease-feedback) hover:text-primary-strong';
+
+interface ProseLinkProps {
+ href: string;
+ children: ReactNode;
+ /** Set for anything leaving the site; adds the target and the rel it needs. */
+ external?: boolean;
+ className?: string;
+}
+
+/**
+ * A link inside a paragraph.
+ *
+ * The same twenty-one links were written out by hand across nine pages in three
+ * incompatible forms — one of them underlined but not coloured, another coloured
+ * but not underlined — and none of them carried the motion tokens.
+ *
+ * `PROSE_LINK` is exported for the handful of places that need the classes on an
+ * element this component cannot render, such as an anchor that also takes a ref.
+ */
+export default function ProseLink({ href, children, external, className }: ProseLinkProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/resources/js/components/ui/section-shell.tsx b/resources/js/components/ui/section-shell.tsx
index 1fb2cf5..2bd9d28 100644
--- a/resources/js/components/ui/section-shell.tsx
+++ b/resources/js/components/ui/section-shell.tsx
@@ -3,6 +3,87 @@ import Container from '@/components/ui/container';
import { AccentLine, FullLine } from '@/components/ui/full-line';
import SectionLabel from '@/components/ui/section-label';
+type Tier = 'argument' | 'reference';
+
+const HEADLINE_CLASS: Record
= {
+ argument: 'text-3xl sm:text-4xl',
+ reference: 'text-2xl',
+};
+
+interface HeaderStackProps {
+ headingId?: string;
+ level: 'h1' | 'h2';
+ label: ReactNode;
+ /** Turns the eyebrow into a link. The blog post header uses it to go back. */
+ labelHref?: string;
+ headline: ReactNode;
+ headlineMuted?: string;
+ lede?: ReactNode;
+ tier: Tier;
+}
+
+/**
+ * The header rhythm, in one place.
+ *
+ * Leading space, an accented label rule, the headline, an optional lede, and a
+ * rule to close. One rule per boundary — this used to emit `FullLine → h-4 →
+ * FullLine` and again `FullLine → h-2 → FullLine`, so every header drew four
+ * rules where it needed two, and a 1px rule / 16px gap / 1px rule band does not
+ * read as a ledger, it reads as a mis-rendered table border.
+ *
+ * Separation comes from each row's own vertical padding, which also fixes 30px
+ * type sitting 3px off its rule while 12px fine print got a full 12px.
+ *
+ * Shared between `SectionShell` and `PageHeader` rather than written twice. Six
+ * pages hand-rolled this stack forty-one times and every copy had drifted: no
+ * accent tick, no vertical padding, `pl-4` instead of `px-4`, and the retired
+ * four-rule pattern intact. Duplicating a rhythm is how a rhythm stops being one.
+ */
+function HeaderStack({ headingId, level, label, labelHref, headline, headlineMuted, lede, tier }: HeaderStackProps) {
+ const Heading = level;
+
+ return (
+ <>
+
+
+
+
+ {labelHref ? (
+
+ {label}
+
+ ) : (
+ {label}
+ )}
+
+
+ {headline}
+ {headlineMuted && (
+ <>
+
+ {headlineMuted}
+ >
+ )}
+
+ {lede && (
+ <>
+
+
+ {lede}
+
+ >
+ )}
+
+
+
+
+ >
+ );
+}
+
interface SectionShellProps {
id: string;
/** Mono uppercase eyebrow. Rendered as a , never a heading. */
@@ -22,7 +103,7 @@ interface SectionShellProps {
* reader scanning for eight seconds had no signal for which sections were
* the argument and which were the appendix.
*/
- tier?: 'argument' | 'reference';
+ tier?: Tier;
/**
* `raised` puts the section on the second ground.
*
@@ -35,23 +116,7 @@ interface SectionShellProps {
children: ReactNode;
}
-const HEADLINE_CLASS: Record<'argument' | 'reference', string> = {
- argument: 'text-3xl sm:text-4xl',
- reference: 'text-2xl',
-};
-
-/**
- * The vertical rhythm every landing section shares: leading space, an accented
- * label rule, the two-tone headline sandwich, an optional lede, then the
- * section body.
- *
- * One rule per boundary. This used to emit `FullLine → h-4 → FullLine` and
- * again `FullLine → h-2 → FullLine`, so every section drew four rules where it
- * needed two — and a 1px rule, 16px gap, 1px rule band does not read as a
- * ledger, it reads as a mis-rendered table border. Separation now comes from
- * the rows' own vertical padding, which also fixes 30px type sitting 3px off
- * its rule while 12px fine print got 12px.
- */
+/** A section of a page, with an H2 and a landmark. */
export default function SectionShell({
id,
label,
@@ -72,38 +137,92 @@ export default function SectionShell({
data-tone={tone === 'raised' ? 'raised' : undefined}
className={`scroll-mt-20 ${className ?? ''}`}
>
-
-
-
-
- {label}
-
-
- {headline}
- {headlineMuted && (
- <>
-
- {headlineMuted}
- >
- )}
-
- {lede && (
- <>
-
-
- {lede}
-
- >
- )}
-
-
+
+ {children}
+
+ );
+}
-
+interface PageHeaderProps {
+ label: ReactNode;
+ labelHref?: string;
+ headline: string;
+ headlineMuted?: string;
+ /**
+ * The lede, or a metadata line such as "Last updated: May 2026". Either way
+ * it is the row under the headline and gets the same treatment; the pages
+ * that hand-rolled this gave the two roles different type and padding for
+ * no reason anyone recorded.
+ *
+ * A node rather than a string, because one of them carries a link.
+ */
+ lede?: ReactNode;
+ children?: ReactNode;
+}
+/**
+ * The same stack, opening a page rather than a section, so it carries the H1.
+ *
+ * Eight pages built this by hand and none of them agreed: three h1 size ramps,
+ * three padding treatments, and the four-rule pattern this component's own
+ * docblock says was deleted.
+ */
+export function PageHeader({ label, labelHref, headline, headlineMuted, lede, children }: PageHeaderProps) {
+ return (
+ <>
+
{children}
-
+ >
+ );
+}
+
+interface SectionHeaderProps {
+ label: ReactNode;
+ headline: ReactNode;
+ headlineMuted?: string;
+ lede?: ReactNode;
+ tier?: Tier;
+}
+
+/**
+ * The header stack on its own, for a section that cannot be wrapped.
+ *
+ * `SectionShell` is the right thing wherever the section's body is a single
+ * block, because it also supplies the landmark, the id and the scroll offset.
+ * Compare and DatabaseClient build their sections inside conditional fragments
+ * whose boundaries do not line up with a component wrapper, so they take the
+ * header alone rather than keeping a second, drifting copy of the rhythm.
+ *
+ * The heading gets no id here: without the `` that
+ * SectionShell provides there is nothing to point at it, and an id that labels
+ * nothing is worse than none.
+ */
+export function SectionHeader({ label, headline, headlineMuted, lede, tier = 'argument' }: SectionHeaderProps) {
+ return (
+
);
}
diff --git a/resources/js/pages/Blog/Index.tsx b/resources/js/pages/Blog/Index.tsx
index 34635b8..1d4c632 100644
--- a/resources/js/pages/Blog/Index.tsx
+++ b/resources/js/pages/Blog/Index.tsx
@@ -4,7 +4,7 @@ import Footer from '@/components/landing/footer';
import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
import { Link } from '@inertiajs/react';
-import SectionLabel from '@/components/ui/section-label';
+import { PageHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
interface PostSummary {
@@ -58,31 +58,8 @@ export default function BlogIndex({ posts, downloadUrls, githubStars }: Props) {
{ name: 'Blog', path: '/blog' },
]}
/>
-
-
-
-
-
-
- Blog
-
-
-
-
-
-
- Database tools, SQL, and Mac.
-
-
-
-
-
-
- {BLOG_DESCRIPTION}
-
-
-
-
+
+
{posts.length === 0 ? (
@@ -94,7 +71,8 @@ export default function BlogIndex({ posts, downloadUrls, githubStars }: Props) {
{post.dateFormatted}
diff --git a/resources/js/pages/Blog/Post.tsx b/resources/js/pages/Blog/Post.tsx
index 0c20168..01d1816 100644
--- a/resources/js/pages/Blog/Post.tsx
+++ b/resources/js/pages/Blog/Post.tsx
@@ -5,8 +5,10 @@ import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
import { Link } from '@inertiajs/react';
import SectionLabel from '@/components/ui/section-label';
+import { PageHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
import Button from '@/components/ui/button';
+import { AppleGlyph } from '@/components/ui/glyph';
interface PostFull {
slug: string;
@@ -98,38 +100,21 @@ export default function BlogPost({ post, relatedPosts, downloadUrls, githubStars
{ name: post.title, path: post.url },
]}
/>
-
-
-
-
-
-
- ← Blog
-
-
-
-
-
-
- {post.title}
-
-
-
-
-
-
-
{post.author}
+
+ {post.author}
·
{post.dateFormatted}
·
{post.readingMinutes} min read
-
-
-
- {post.tags.length > 0 && (
+
+ }
+ />
+{post.tags.length > 0 && (
<>
@@ -171,7 +156,8 @@ export default function BlogPost({ post, relatedPosts, downloadUrls, githubStars
{rp.dateFormatted}
@@ -205,7 +191,7 @@ export default function BlogPost({ post, relatedPosts, downloadUrls, githubStars
-
+
Download for Mac
diff --git a/resources/js/pages/Compare.tsx b/resources/js/pages/Compare.tsx
index 174cdcd..7d514f5 100644
--- a/resources/js/pages/Compare.tsx
+++ b/resources/js/pages/Compare.tsx
@@ -4,9 +4,13 @@ import Footer from '@/components/landing/footer';
import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
import { getComparisonBySlug } from '@/data/comparisons';
-import SectionLabel from '@/components/ui/section-label';
+import { PageHeader, SectionHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
import Button from '@/components/ui/button';
+import { AppleGlyph, Availability, CheckGlyph } from '@/components/ui/glyph';
+import FaqList from '@/components/ui/faq-list';
+import ThemedImage from '@/components/ui/themed-image';
+import DataTable, { TABLE_COLUMN_RULE, TABLE_ROW_RULE } from '@/components/ui/data-table';
interface Props {
slug: string;
@@ -24,44 +28,10 @@ interface Props {
* `muted-foreground/30` measured 1.51:1: both were effectively invisible to
* anyone who needed them most.
*/
-function Check() {
- return (
- <>
- Included
-
-
-
- >
- );
-}
-function Cross() {
- return (
- <>
- Not included
-
-
-
- >
- );
-}
function CellValue({ value }: { value: string | boolean }) {
- if (typeof value === 'boolean') return value ? : ;
+ if (typeof value === 'boolean') return ;
return {value} ;
}
@@ -134,73 +104,59 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
{ name: comp.name, path: canonical },
]}
/>
-
- {/* Hero */}
-
-
-
-
-
- Compare
-
-
-
-
-
-
-
-
-
- {comp.tagline}
-
-
-
-
-
-
-
-
-
- {comp.longDescription}
-
-
-
-
- {/* Comparison Table */}
+
+{/* Comparison Table */}
-
-
- {/* Header */}
-
-
- Feature
-
-
- TablePro
-
-
- {comp.name}
-
-
-
- {/* Rows */}
- {comp.rows.map((row) => (
-
-
- {row.label}
-
-
-
-
-
-
-
-
- ))}
-
+ {/*
+ * Same shape as the plan comparison on the homepage: a real
+ * table, column separators at the column weight, and the
+ * scroll container reachable by keyboard. This was a grid of
+ * divs with no table semantics, drawing its columns at the
+ * row weight — in the one table where the columns carry the
+ * entire meaning.
+ */}
+
+
+
+
+
+ Feature
+
+
+ TablePro
+
+
+ {comp.name}
+
+
+
+
+ {comp.rows.map((row) => (
+
+
+ {row.label}
+
+
+
+
+
+
+
+
+ ))}
+
+
@@ -208,28 +164,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
{/* Benchmarks */}
{comp.benchmarks && (
<>
-
-
-
-
-
- Benchmarks
-
-
-
-
-
-
-
-
-
- The numbers.
-
-
-
-
-
-
+
@@ -260,28 +198,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
)}
{/* Pros */}
-
-
-
-
-
- Summary
-
-
-
-
-
-
-
-
-
- Strengths of each.
-
-
-
-
-
-
+
@@ -291,7 +211,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
{comp.prosTablePro.map((pro) => (
-
+
{pro}
))}
@@ -304,7 +224,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
{comp.prosCompetitor.map((pro) => (
-
+
{pro}
))}
@@ -317,28 +237,10 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
{/* Migration guide */}
{comp.migrationSteps && comp.migrationSteps.length > 0 && (
<>
-
-
-
-
-
- Migration
-
-
-
-
-
-
-
-
-
- Switch from {comp.name}.
-
-
-
-
-
-
+ Switch from {comp.name}.>}
+ />
@@ -360,84 +262,29 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
)}
{/* App Screenshot */}
-
-
-
-
-
- TablePro
-
-
-
-
-
-
-
-
-
- See it in action.
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
+
{/* Who Should Choose What */}
-
-
-
-
-
- Decision
-
-
-
-
-
-
-
-
-
- Which one is right for you?
-
-
-
-
-
-
+
@@ -452,21 +299,8 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
-
- {/* Verdict */}
-
-
-
-
-
- Verdict
-
-
-
-
-
-
-
+
+
@@ -479,43 +313,13 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
{/* FAQ */}
{comp.faqs && comp.faqs.length > 0 && (
<>
-
-
-
-
-
- FAQ
-
-
-
-
-
-
-
-
-
- Common questions.
-
-
-
-
-
-
+
-
- {comp.faqs.map((faq) => (
-
-
- {faq.question}
-
-
-
-
- {faq.answer}
-
- ))}
-
+
>
@@ -537,7 +341,7 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) {
-
+
Download for Mac
diff --git a/resources/js/pages/DatabaseClient.tsx b/resources/js/pages/DatabaseClient.tsx
index d10c396..950433b 100644
--- a/resources/js/pages/DatabaseClient.tsx
+++ b/resources/js/pages/DatabaseClient.tsx
@@ -4,9 +4,12 @@ import Footer from '@/components/landing/footer';
import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
import { getDatabaseBySlug } from '@/data/databases';
-import SectionLabel from '@/components/ui/section-label';
+import { PageHeader, SectionHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
import Button from '@/components/ui/button';
+import { AppleGlyph, CheckGlyph } from '@/components/ui/glyph';
+import FaqList from '@/components/ui/faq-list';
+import ThemedImage from '@/components/ui/themed-image';
interface Props {
slug: string;
@@ -96,40 +99,18 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
/>
{/* Hero */}
-
-
-
-
-
-
-
+
+
{db.name} Client
-
-
-
-
-
-
-
-
-
-
- {db.tagline}
-
-
-
+
+ }
+ headline={db.tagline}
+ lede={db.longDescription}
+ />
-
-
-
-
-
- {db.longDescription}
-
-
-
-
-
+
@@ -137,7 +118,7 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
-
+
Download for Mac
-
-
-
-
-
-
-
-
+
{/* Features */}
-
-
-
-
-
- Features
-
-
-
-
-
-
-
-
-
- Built for {db.name}.
-
-
-
-
-
-
+ Built for {db.name}.>}
+ />
@@ -225,143 +171,61 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
{/* Data Grid Screenshot */}
-
-
-
-
-
- Data Grid
-
-
-
-
-
-
-
-
-
- Browse and edit data.
-
-
-
-
-
-
-
-
-
- Sort, filter, and edit {db.name} data in a spreadsheet-like grid. Click any cell to edit.
- Insert and delete rows. Review changes before saving.
-
-
-
-
-
-
+
Sort, filter, and edit {db.name} data in a spreadsheet-like grid. Click any cell to edit. Insert and delete rows. Review changes before saving.>}
+ />
-
-
{/* SQL Editor Screenshot */}
-
-
-
-
-
- SQL Editor
-
-
-
-
-
-
-
-
-
- Write queries faster.
-
-
-
-
-
-
-
-
-
- Tree-sitter syntax highlighting, schema-aware autocomplete, multi-tab, Vim mode, and full-text query history.
- AI assistant can write, explain, or optimize your SQL.
-
-
-
-
-
-
+
-
-
{/* What You Can Do */}
-
-
-
-
-
- Capabilities
-
-
-
-
-
-
-
-
-
- What you can do.
-
-
-
-
-
-
+
{db.capabilities.map((cap) => (
-
-
-
+
{cap}
))}
@@ -371,34 +235,16 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
{/* Connection Info */}
-
-
-
-
-
- Connect
-
-
-
-
-
-
-
-
-
- Get connected in seconds.
-
-
-
-
-
-
+
{/* Code snippet */}
-
+
{db.snippet}
@@ -434,28 +280,10 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
{/* How to connect */}
{db.howToSteps && db.howToSteps.length > 0 && (
<>
-
-
-
-
-
- How to
-
-
-
-
-
-
-
-
-
- Connect to {db.name} in four steps.
-
-
-
-
-
-
+
Connect to {db.name} in four steps.>}
+ />
@@ -479,43 +307,13 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
{/* FAQ */}
{db.faqs && db.faqs.length > 0 && (
<>
-
-
-
-
-
- FAQ
-
-
-
-
-
-
-
-
-
- Common {db.name} questions.
-
-
-
-
-
-
+ Common {db.name} questions.>}
+ />
-
- {db.faqs.map((faq) => (
-
-
- {faq.question}
-
-
-
-
- {faq.answer}
-
- ))}
-
+
>
@@ -537,7 +335,7 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop
-
+
Download for Mac
+
);
return (
@@ -119,42 +122,17 @@ export default function Download({ downloadUrls, githubStars }: Props) {
{ name: 'Download', path: '/download' },
]}
/>
-
-
-
- {/* Label */}
-
-
-
- Download
-
-
-
-
- {/* Spacer */}
-
-
- {/* Headline */}
-
-
-
- Your download is starting...
-
-
-
-
- {/* Manual link */}
-
-
-
-
- If your download doesn't start automatically,{' '}
- click here .
-
-
-
-
- {/* Spacer */}
+
+ If your download doesn't start automatically,{' '}
+ click here .
+ >
+ }
+ />
+{/* Spacer */}
{/* Architecture selector */}
@@ -201,7 +179,7 @@ export default function Download({ downloadUrls, githubStars }: Props) {
1
- Open the downloaded .dmg file
+ Open the downloaded .dmg file
2
@@ -224,7 +202,7 @@ export default function Download({ downloadUrls, githubStars }: Props) {
href="https://github.com/TableProApp/TablePro/releases"
target="_blank"
rel="noopener noreferrer"
- className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-foreground underline underline-offset-4 transition-colors hover:text-primary-strong"
+ className={cn(PROSE_LINK, 'mt-4 inline-flex items-center gap-1.5 text-sm font-medium')}
>
View all releases
diff --git a/resources/js/pages/Privacy.tsx b/resources/js/pages/Privacy.tsx
index c10e9f9..c1e50fb 100644
--- a/resources/js/pages/Privacy.tsx
+++ b/resources/js/pages/Privacy.tsx
@@ -3,28 +3,16 @@ import Header from '@/components/landing/header';
import Footer from '@/components/landing/footer';
import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
-import SectionLabel from '@/components/ui/section-label';
+import { PageHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
+import { Bullet, ProseBlock } from '@/components/ui/prose-block';
+import { PROSE_LINK } from '@/components/ui/prose-link';
interface Props {
downloadUrls: { arm64: string; x86_64: string };
}
-function Bullet() {
- return ;
-}
-function SectionBlock({ title, children }: { title: string; children: React.ReactNode }) {
- return (
- <>
-
-
- >
- );
-}
export default function Privacy({ downloadUrls }: Props) {
return (
@@ -38,58 +26,33 @@ export default function Privacy({ downloadUrls }: Props) {
{ name: 'Privacy', path: '/privacy' },
]}
/>
-
-
-
-
-
-
- Legal
-
-
-
-
-
-
-
-
-
- Privacy Policy
-
-
-
-
-
-
-
-
- Last updated: May 2026
-
-
-
-
-
+
+
TablePro collects minimal data, never touches your database contents, and lets you opt out of analytics. The codebase is{' '}
- open source on GitHub under the AGPLv3, so you can verify exactly what is collected and how it is handled.
+ open source on GitHub under the AGPLv3, so you can verify exactly what is collected and how it is handled.
This policy covers the desktop and mobile Application, the Website at tablepro.app, and the account portal at tablepro.app/account.
-
+
TablePro is the data controller for personal information described in this policy. For privacy questions, email{' '}
- hello@tablepro.app .
+ hello@tablepro.app .
-
+
-
+
Anonymous usage analytics (desktop app)
The Application can send anonymous usage analytics. It is{' '}
@@ -98,7 +61,7 @@ export default function Privacy({ downloadUrls }: Props) {
When enabled, a 24-hour heartbeat sends three things to{' '}
- https://api.tablepro.app/v1/analytics:
+ https://api.tablepro.app/v1/analytics:
Anonymous machine ID : SHA-256 hash of your hardware UUID. The raw UUID is never sent.
@@ -112,7 +75,7 @@ export default function Privacy({ downloadUrls }: Props) {
Update checks
The Application uses{' '}
- Sparkle to check for updates. This sends your{' '}
+ Sparkle to check for updates. This sends your{' '}
app version , macOS version , and CPU architecture to our update server. Update checks cannot be disabled separately.
@@ -127,7 +90,7 @@ export default function Privacy({ downloadUrls }: Props) {
The account portal at{' '}
- tablepro.app/account {' '}
+ tablepro.app/account {' '}
uses magic-link authentication. We store your email and an authentication token for that purpose.
@@ -140,9 +103,9 @@ export default function Privacy({ downloadUrls }: Props) {
Our servers record standard request logs (IP address, user agent, timestamp, URL) for security and abuse prevention. These logs are retained for up to 90 days.
-
+
-
+
No database contents or queries . SQL you write and data you fetch never leave your machine.
No connection credentials . Database hosts, usernames, and passwords stay in the macOS Keychain.
@@ -150,9 +113,9 @@ export default function Privacy({ downloadUrls }: Props) {
No crash reports sent to any third party.
No third-party trackers . No Google Analytics, Mixpanel, Sentry, or similar SDK in the desktop app.
-
+
-
+
Anonymous analytics : understand which app versions, OS versions, and database types our users run, to prioritise compatibility and bug fixes.
License validation : confirm a License Key is valid and active.
@@ -161,9 +124,9 @@ export default function Privacy({ downloadUrls }: Props) {
Newsletter and announcements : only if you subscribed.
Security : detect abuse and unauthorised access attempts.
-
+
-
+
For users in the European Economic Area or the United Kingdom, the legal bases under GDPR / UK GDPR are:
Contract (Art. 6(1)(b)) : processing payment, providing the License Key, account portal access.
@@ -171,9 +134,9 @@ export default function Privacy({ downloadUrls }: Props) {
Consent (Art. 6(1)(a)) : newsletter subscriptions, optional features you enable.
Legal obligation (Art. 6(1)(c)) : tax records, responses to lawful requests.
-
+
-
+
We share personal data only with the providers needed to operate the Services:
LemonSqueezy or Polar : payment processing for License Key purchases.
@@ -184,15 +147,15 @@ export default function Privacy({ downloadUrls }: Props) {
We do not sell, rent, or share personal data with advertisers.
-
+
-
+
Our servers operate in multiple regions. When you interact with TablePro, your data may be transferred to or processed in countries outside your own. Where required, transfers from the EEA / UK rely on Standard Contractual Clauses or other approved mechanisms.
-
+
-
+
Anonymous analytics : aggregated indefinitely; the SHA-256 machine ID has no link to your identity.
Account and license data : kept while your license is active and for up to 7 years afterward for tax and audit purposes.
@@ -200,9 +163,9 @@ export default function Privacy({ downloadUrls }: Props) {
Server logs : 90 days.
Support emails : 2 years from the last interaction.
-
+
-
+
Subject to local law, you have the right to:
@@ -218,30 +181,30 @@ export default function Privacy({ downloadUrls }: Props) {
To exercise any of these rights, email{' '}
- hello@tablepro.app . We respond within 30 days.
+ hello@tablepro.app . We respond within 30 days.
-
+
-
+
If you are a California resident, the California Consumer Privacy Act gives you the right to know what personal information we collect, to request deletion, to opt out of "sale" of personal information, and to non-discrimination for exercising these rights. We do not sell personal information.
-
+
-
+
TablePro is not directed at children under 16. We do not knowingly collect personal data from children. If you believe a child has provided us data, contact us and we will delete it.
-
+
-
+
We use industry-standard measures to protect data: HTTPS for all network traffic, password hashing with modern algorithms, scoped API tokens, and least-privilege access for our team. No system is perfectly secure; if you suspect a vulnerability, email{' '}
- hello@tablepro.app .
+ hello@tablepro.app .
-
+
-
+
The marketing site uses two functional cookies. No tracking, no advertising, no third-party SDKs.
@@ -249,9 +212,9 @@ export default function Privacy({ downloadUrls }: Props) {
nl_dismissed_at (90 days): records when you dismissed the newsletter prompt so we don't reshow it. Lawful basis: legitimate interest.
nl_subscribed (365 days): records that you subscribed so we don't reprompt. Lawful basis: legitimate interest, performance of a subscription you initiated.
-
+
-
+
Sensitive data stays on your Mac:
Database credentials : macOS Keychain.
@@ -259,28 +222,28 @@ export default function Privacy({ downloadUrls }: Props) {
App settings : standard macOS UserDefaults.
Tab state : local JSON files for session restore.
-
+
-
+
The Application source is on{' '}
- GitHub under the AGPLv3. The analytics code is in{' '}
- TablePro/Core/Services/AnalyticsService.swift. You can verify what is sent.
+ GitHub under the AGPLv3. The analytics code is in{' '}
+ TablePro/Core/Services/AnalyticsService.swift. You can verify what is sent.
-
+
-
+
We may update this policy. Material changes will be posted on this page with a new "Last updated" date and, where required by law, notified to users.
-
+
-
+
For privacy questions, security reports, or anything else, email{' '}
- hello@tablepro.app .
+ hello@tablepro.app .
-
+
diff --git a/resources/js/pages/RefundPolicy.tsx b/resources/js/pages/RefundPolicy.tsx
index e177b6e..973a58f 100644
--- a/resources/js/pages/RefundPolicy.tsx
+++ b/resources/js/pages/RefundPolicy.tsx
@@ -3,8 +3,9 @@ import Header from '@/components/landing/header';
import Footer from '@/components/landing/footer';
import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
-import SectionLabel from '@/components/ui/section-label';
+import { PageHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
+import { PROSE_LINK } from '@/components/ui/prose-link';
interface Props {
downloadUrls: { arm64: string; x86_64: string };
@@ -22,37 +23,12 @@ export default function RefundPolicy({ downloadUrls }: Props) {
{ name: 'Refund Policy', path: '/refund-policy' },
]}
/>
-
-
-
-
-
-
- Legal
-
-
-
-
-
-
-
-
-
- Refund Policy
-
-
-
-
-
-
-
-
- Last updated: May 2026
-
-
-
-
-
+
+
@@ -62,7 +38,7 @@ export default function RefundPolicy({ downloadUrls }: Props) {
To request a refund, email{' '}
- hello@tablepro.app {' '}
+ hello@tablepro.app {' '}
with the email or License Key from your purchase. Eligible refunds are processed within 5 business days to the original payment method.
diff --git a/resources/js/pages/Terms.tsx b/resources/js/pages/Terms.tsx
index 7b13437..716a491 100644
--- a/resources/js/pages/Terms.tsx
+++ b/resources/js/pages/Terms.tsx
@@ -3,28 +3,16 @@ import Header from '@/components/landing/header';
import Footer from '@/components/landing/footer';
import Container from '@/components/ui/container';
import SEOHead from '@/components/seo/seo-head';
-import SectionLabel from '@/components/ui/section-label';
+import { PageHeader } from '@/components/ui/section-shell';
import { FullLine } from '@/components/ui/full-line';
+import { Bullet, ProseBlock } from '@/components/ui/prose-block';
+import { PROSE_LINK } from '@/components/ui/prose-link';
interface Props {
downloadUrls: { arm64: string; x86_64: string };
}
-function Bullet() {
- return ;
-}
-function SectionBlock({ title, children }: { title: string; children: React.ReactNode }) {
- return (
- <>
-
-
- >
- );
-}
export default function Terms({ downloadUrls }: Props) {
return (
@@ -38,50 +26,25 @@ export default function Terms({ downloadUrls }: Props) {
{ name: 'Terms', path: '/terms' },
]}
/>
-
-
-
-
-
-
- Legal
-
-
-
-
-
-
-
-
-
- Terms of Service
-
-
-
-
-
-
-
-
- Last updated: May 2026
-
-
-
-
-
+
+
These Terms of Service govern your use of TablePro, the website at{' '}
- tablepro.app ,
+ tablepro.app ,
the desktop application, the iOS application, and any related services. By downloading, installing, accessing, or using
any part of TablePro, you agree to these Terms. If you do not agree, do not use TablePro.
-
+
"TablePro", "we", "us", "our" refers to the TablePro project and its maintainers.
"Application" means the TablePro desktop and mobile software, including all updates and plugins.
@@ -90,21 +53,21 @@ export default function Terms({ downloadUrls }: Props) {
"License Key" means the credential that activates paid features in the Application.
"You", "your" means the individual or entity using the Application or Services.
-
+
-
+
The Application source code is licensed under the{' '}
- GNU Affero General Public License v3 (AGPLv3) .
+ GNU Affero General Public License v3 (AGPLv3) .
You may use, modify, and distribute it under those terms. The full license text is at{' '}
- github.com/TableProApp/TablePro/LICENSE .
+ github.com/TableProApp/TablePro/LICENSE .
The free tier covers core functionality and is enough for most personal and commercial use. No registration is required to use the free tier.
-
+
-
+
Some advanced features require a paid License Key. When you purchase a License Key, you receive a non-exclusive, non-transferable right to enable those features for the number of devices and the duration covered by your plan.
@@ -113,17 +76,17 @@ export default function Terms({ downloadUrls }: Props) {
Refund eligibility is described in the{' '}
- Refund Policy .
+ Refund Policy .
-
+
-
+
License Keys are managed through an account portal accessed by magic link sent to the email address you used at purchase. You are responsible for keeping that email account secure. We are not responsible for unauthorized access to your account caused by a compromised email account.
-
+
-
+
You agree not to:
Use the Application or Services in violation of any law or third-party right.
@@ -132,9 +95,9 @@ export default function Terms({ downloadUrls }: Props) {
Use the Services to transmit malware or carry out denial-of-service attacks.
Resell or redistribute License Keys without our written permission.
-
+
-
+
You are solely responsible for:
Securing your database credentials and License Keys.
@@ -146,18 +109,18 @@ export default function Terms({ downloadUrls }: Props) {
You acknowledge and agree that your use of the Application can affect your databases, and you accept{' '}
sole responsibility for any errors, malfunctions, or corruption of any database caused by your use of the Application, including queries you run, scripts you execute, and changes you commit.
-
+
-
+
THE APPLICATION AND SERVICES ARE PROVIDED "AS IS" AND "AS AVAILABLE" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, ACCURACY, OR UNINTERRUPTED OPERATION.
We do not warrant that the Application will meet your requirements, be compatible with any specific database version, operate without error, or that any errors will be corrected.
-
+
-
+
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL TABLEPRO OR ITS CONTRIBUTORS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE DAMAGES, INCLUDING BUT NOT LIMITED TO: LOSS OF DATA, LOSS OF DATABASE CONTENTS, DATA CORRUPTION, LOSS OF PROFITS, LOSS OF BUSINESS, BUSINESS INTERRUPTION, LOSS OF GOODWILL, OR THE COST OF SUBSTITUTE GOODS OR SERVICES, ARISING OUT OF OR RELATED TO YOUR USE OF THE APPLICATION OR SERVICES, EVEN IF WE HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
@@ -167,74 +130,74 @@ export default function Terms({ downloadUrls }: Props) {
Some jurisdictions do not allow the exclusion or limitation of certain damages. In such jurisdictions, our liability is limited to the minimum extent permitted by law.
-
+
-
+
You agree to indemnify and hold harmless TablePro and its contributors from any claim, demand, loss, or damage (including reasonable legal fees) arising out of your use of the Application or Services in violation of these Terms or any third-party right.
-
+
-
+
We may release updates, fixes, and new features at our discretion. We have no obligation to maintain, update, or provide support for any version of the Application. Older versions may stop receiving security updates.
Some Services (license validation, update checks) require network access to function. We do not guarantee continuous availability of these Services and may discontinue them with reasonable notice.
-
+
-
+
You may stop using the Application and Services at any time. We may suspend or terminate your access to paid features without refund if you breach these Terms, including the Acceptable Use clause.
Sections that by their nature survive termination (Disclaimer, Limitation of Liability, Indemnification, Governing Law) will continue to apply after termination.
-
+
-
+
Our handling of personal data is described in the{' '}
- Privacy Policy . By using the Application or Services, you agree to that policy.
+ Privacy Policy . By using the Application or Services, you agree to that policy.
-
+
-
+
We may update these Terms from time to time. Material changes will be announced on this page with a new "Last updated" date. Continuing to use the Application or Services after the update means you accept the new Terms.
-
+
-
+
These Terms are governed by the laws of the Socialist Republic of Vietnam, without regard to conflict of law principles. Any dispute arising out of or relating to these Terms or your use of the Application or Services shall be resolved in the competent courts of Vietnam.
If you reside in a jurisdiction with mandatory consumer protection laws, those laws apply to the extent they cannot be waived.
-
+
-
+
If any provision of these Terms is held invalid or unenforceable by a court of competent jurisdiction, the remaining provisions remain in full force and effect.
-
+
-
+
These Terms, together with the AGPLv3 license, the Privacy Policy, and the Refund Policy, constitute the entire agreement between you and TablePro regarding the Application and Services and supersede any prior agreements.
-
+
-
+
For questions about these Terms, email{' '}
- hello@tablepro.app {' '}
+ hello@tablepro.app {' '}
or open an issue at{' '}
- github.com/TableProApp/TablePro/issues .
+ github.com/TableProApp/TablePro/issues .
-
+