From 52f6fcbfe98f6fa85268609836d82ca8458b3f25 Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Sat, 15 Aug 2026 09:41:20 +0700
Subject: [PATCH 1/2] refactor(ui): route every pill button through the
primitive
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nineteen buttons, four padding scales, one unused primitive. They now all go
through it, and two hazards had to be handled first — neither of which a test
here would have caught.
**Download.tsx assigns `href` imperatively.** It resolves the real asset URL
after mount and writes it onto the anchor through a ref. A button component that
swallowed the ref would have left both download buttons pointing at the generic
releases page: a broken download, on the download page, silently. React 19
passes `ref` to function components as an ordinary prop, so the primitive takes
one.
**Download.tsx also swaps the two buttons' classes at runtime**, to make
whichever architecture it detected the primary one. That hard-coded both class
strings, so the imperative half and the rendered half would drift apart the
first time either changed — which is the exact failure the primitive exists to
prevent, reintroduced one layer down. `buttonClasses()` is now exported and both
halves read from it.
Three buttons keep their own element and borrow only the class list, because
they are not links: the header's dropdown trigger carries `aria-expanded` and
`aria-haspopup` and a keyboard handler, the iPhone toggle carries
`aria-controls`, and the email form's is a submit. Sharing the classes without
forcing the element is the right shape for those.
---
.../js/components/landing/download-rail.tsx | 8 ++--
resources/js/components/landing/header.tsx | 3 +-
.../js/components/landing/mobile-nav.tsx | 9 ++--
resources/js/components/ui/button.tsx | 41 ++++++++++++++-----
resources/js/pages/Download.tsx | 15 ++++---
5 files changed, 46 insertions(+), 30 deletions(-)
diff --git a/resources/js/components/landing/download-rail.tsx b/resources/js/components/landing/download-rail.tsx
index 52042e9..471a93a 100644
--- a/resources/js/components/landing/download-rail.tsx
+++ b/resources/js/components/landing/download-rail.tsx
@@ -1,3 +1,4 @@
+import Button from '@/components/ui/button';
import Container from '@/components/ui/container';
import { FullLine } from '@/components/ui/full-line';
@@ -37,13 +38,10 @@ export default function DownloadRail({ note }: Props) {
From a90a1a8909a6bef99923dc45ae129b6510649444 Mon Sep 17 00:00:00 2001
From: Ngo Quoc Dat
Date: Sat, 15 Aug 2026 09:41:20 +0700
Subject: [PATCH 2/2] feat(landing): ask the blocking questions where they
arise, and cut the index
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four content decisions, and one script so the fifth can be answered.
**The two questions that stop a download now sit under the hero.** "Is it really
free, or free for now?" and "Can I use TablePro at work under AGPLv3?" used to
be items one and two of a FAQ at position fifteen — behind roughly three
thousand words, past the point where a reader holding either objection has
already left. The AGPL one blocks the highest-value visitor there is: someone
evaluating a Team licence for a company.
**"Can the AI drop a table on production?" moves into the Agents section**,
which is the section that raises the fear. It answered it two positions and
several hundred words later.
**"Can I move my connections from another client?" is dropped** from the
homepage: SwitchFrom is an entire section on exactly that, and it now sits at
position seven rather than eleven.
None of these leave the site. `home-faqs.ts` splits into `homeFaqs` (the four
that stay in the FAQ block) and `relocatedFaqs` (the four rendered inline), and
`/faq` spreads both — so it still lists all fourteen, and each answer exists in
exactly one place. Both StaleClaimsTest counts stay true, and still mean what
they say.
**DepthGrid goes from twelve cells to six.** Three repeated a section above it —
Vim mode from the workbench ledger, connection import from the whole SwitchFrom
section, plugin verification from the database grid's own lede — so cutting them
is deduplication, not taste. Three more were table stakes every competitor also
ships. The `spec` line goes with them: shortcuts, thresholds and defaults are
genuinely useful and genuinely documentation, and they were roughly 250 words
standing between the reader and the next call to action. The section's own
comment called itself "an index rather than twelve more pitches"; it is now an
index of six.
**/faq gets an h1.** It had none at all — SectionShell emits h2, so the page
started its heading tree at level two. sr-only, because the FAQ section already
opens with its own headline and two stacked headlines saying the same thing is
worse than none.
**Sponsors gain a description slot.** Structure only: the copy is eight facts
about eight real companies and is not mine to write. A sponsor without one still
renders its mark, so filling them in later is a data edit.
**scripts/measure-cold-start.sh** measures launch-to-first-window for TablePro
and any competitor you name, purging the page cache between runs and refusing to
report if it could not — a warm start quoted as a cold one is exactly what this
is meant to prevent. It prints hardware, OS build, version and range along with
the median. The point is not the number; it is that the method is runnable by
anyone, which is what turns "Under 1s" from a claim into a measurement.
---
.../js/components/landing/agents-mcp.tsx | 19 +++
.../js/components/landing/depth-grid.tsx | 61 ++-------
.../js/components/landing/footer-cta.tsx | 12 +-
resources/js/components/landing/hero.tsx | 15 +--
resources/js/components/landing/mobile.tsx | 6 +-
.../js/components/landing/objection-row.tsx | 52 +++++++
resources/js/components/landing/sponsors.tsx | 19 ++-
resources/js/data/faqs.ts | 11 +-
resources/js/data/home-faqs.ts | 63 ++++++---
resources/js/pages/Blog/Post.tsx | 8 +-
resources/js/pages/Compare.tsx | 8 +-
resources/js/pages/DatabaseClient.tsx | 25 ++--
resources/js/pages/Faq.tsx | 11 +-
resources/js/pages/Home.tsx | 9 ++
scripts/measure-cold-start.sh | 127 ++++++++++++++++++
15 files changed, 336 insertions(+), 110 deletions(-)
create mode 100644 resources/js/components/landing/objection-row.tsx
create mode 100755 scripts/measure-cold-start.sh
diff --git a/resources/js/components/landing/agents-mcp.tsx b/resources/js/components/landing/agents-mcp.tsx
index 4a2dd7e..104d315 100644
--- a/resources/js/components/landing/agents-mcp.tsx
+++ b/resources/js/components/landing/agents-mcp.tsx
@@ -5,6 +5,7 @@ import Container from '@/components/ui/container';
import { FullLine } from '@/components/ui/full-line';
import { Ledger, LedgerRow } from '@/components/ui/ledger';
import SectionShell from '@/components/ui/section-shell';
+import { relocatedFaq } from '@/data/home-faqs';
import { cellBorders, type ColumnMap } from '@/components/ui/grid-cell';
/** Kept byte-identical to the tokenized block below, since this is what gets copied. */
@@ -112,6 +113,8 @@ function CopyButton() {
);
}
+const aiSafetyFaq = relocatedFaq('Can the AI drop a table on production?');
+
/**
* No screenshot exists for the MCP server, so this section stays typographic:
* the config you paste on the left, the permission ladder on the right.
@@ -215,6 +218,22 @@ export default function AgentsMcp() {
+ {/*
+ * This section raises the most alarming claim on the page, so it
+ * carries the first half of the answer rather than deferring all of
+ * it to the FAQ. Verbatim from relocatedFaqs — one copy of the
+ * answer, and /faq still lists it.
+ */}
+
+
+
{aiSafetyFaq.question}
+
+ {aiSafetyFaq.answer}
+
+
+
+
+
Remote access is off by default. Turning it on switches authentication and TLS on automatically,
diff --git a/resources/js/components/landing/depth-grid.tsx b/resources/js/components/landing/depth-grid.tsx
index 645ab89..68c028c 100644
--- a/resources/js/components/landing/depth-grid.tsx
+++ b/resources/js/components/landing/depth-grid.tsx
@@ -4,15 +4,13 @@ import { FullLine } from '@/components/ui/full-line';
import SectionShell from '@/components/ui/section-shell';
import { cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell';
-/** 12 items divide evenly into 1, 2 and 3 columns, so no filler cells are needed. */
+/** 6 items divide evenly into 1, 2 and 3 columns, so no filler cells are needed. */
const COLS: ColumnMap = { base: 1, sm: 2, lg: 3 };
interface DepthItem {
title: string;
/** One sentence. A node only where the copy carries an inline mono token. */
body: ReactNode;
- /** Rendered mono already, so shortcuts and identifiers stay plain text. */
- spec: string;
}
function Mono({ children }: { children: ReactNode }) {
@@ -20,44 +18,21 @@ function Mono({ children }: { children: ReactNode }) {
}
const ITEMS: DepthItem[] = [
- {
- title: 'ER diagram',
- body: 'Automatic two-dimensional layout that groups tables by foreign key instead of stacking one tall column.',
- spec: "crow's foot from PKs and unique indexes · junction tables collapse · export PNG or CREATE TABLE",
- },
{
title: 'EXPLAIN, visualized',
body: 'Three views of the plan: a cost-coloured diagram, an expandable tree, and the raw text.',
- spec: '⌘⌥E · ClickHouse Plan/Pipeline/AST · Trino Logical/Distributed/IO · BigQuery dry-run cost',
},
{
title: 'Server dashboard',
body: 'Active sessions, per-engine metrics and slow queries, with Cancel Query and Terminate Session behind a confirmation.',
- spec: 'pg_cancel_backend and KILL QUERY · slow = over 1s · refresh 1s to 30s, default 5s',
},
{
title: 'Users and roles',
body: 'Grant and revoke without hand-writing GRANT, from the server down to a single column.',
- spec: 'MySQL, MariaDB, PostgreSQL, PGlite · staged as a diff · PostgreSQL applies in one transaction',
- },
- {
- title: 'Vim mode',
- body: (
- <>
- Six modes with the full motion and operator set, so :w runs the query.
- >
- ),
- spec: 'counts, text objects, registers "a to "z, marks, macros capped at 50 recursions',
- },
- {
- title: 'Query history',
- body: 'Every query you run is saved, successful or not, and searchable.',
- spec: 'local SQLite with an FTS5 index · 10,000 entries, 90 days, both configurable · ⌘Y',
},
{
title: 'Quick Switcher',
body: 'Fuzzy search across tables, databases, saved queries and history, ranked by frecency.',
- spec: '⌘⇧O · four scopes on ⌘1 to ⌘4 · ⌘K switches database, and says Keyspace on Cassandra',
},
{
title: 'CSV inspector',
@@ -67,37 +42,30 @@ const ITEMS: DepthItem[] = [
step.
>
),
- spec: 'detects delimiter, encoding and line ending · split by regex, merge columns · saves byte-faithfully',
- },
- {
- title: 'Import and export',
- body: 'Five export formats, streaming at constant memory with no row limit, written atomically.',
- spec: 'CSV, JSON, SQL, MQL, XLSX · imports SQL, JSON, CSV with rollback, commit or skip',
- },
- {
- title: 'Connection import',
- body: 'Bring your connections from six other clients, passwords and tunnels included.',
- spec: 'TablePlus, Sequel Ace, DBeaver, DataGrip, Beekeeper Studio, Navicat · source app need not be running',
},
{
title: 'Backup and restore',
body: "PostgreSQL and Redshift dumps through the connection's existing SSH tunnel.",
- spec: 'pg_dump -Fc · live byte counter · restore runs --no-owner --no-acl',
- },
- {
- title: 'Plugins and themes',
- body: 'Drivers install when you pick them and update without a restart.',
- spec: 'SHA-256 and code signature checked before load · staged updates · four built-in themes, more from the registry',
},
];
/**
* The long tail, kept to one flush grid so it reads as an index rather than
- * twelve more pitches. The mono ordinal is the only accent in each cell.
+ * six more pitches. The mono ordinal is the only accent in each cell.
+ *
+ * Six, not twelve. Three of the originals repeated a section above — Vim mode
+ * from the workbench ledger, connection import from the whole SwitchFrom
+ * section, plugin verification from the database grid's own lede — and three
+ * were table stakes every competitor also ships. What is left is the set a
+ * reader could not assume.
+ *
+ * The `spec` line went with them. It carried shortcuts, thresholds and defaults
+ * — genuinely useful, genuinely documentation, and roughly 250 words standing
+ * between the reader and the next call to action.
*/
export default function DepthGrid() {
return (
-
+
@@ -114,9 +82,6 @@ export default function DepthGrid() {
diff --git a/resources/js/components/landing/objection-row.tsx b/resources/js/components/landing/objection-row.tsx
new file mode 100644
index 0000000..d8c25c6
--- /dev/null
+++ b/resources/js/components/landing/objection-row.tsx
@@ -0,0 +1,52 @@
+import Container from '@/components/ui/container';
+import { FullLine } from '@/components/ui/full-line';
+import { cellBorders, type ColumnMap } from '@/components/ui/grid-cell';
+import { relocatedFaq } from '@/data/home-faqs';
+
+const COLS: ColumnMap = { base: 1, md: 2 };
+
+const QUESTIONS = [
+ 'Is it really free, or free for now?',
+ 'Can I use TablePro at work under AGPLv3?',
+] as const;
+
+/**
+ * The two questions that stop a download, answered where they arise.
+ *
+ * Both used to sit in the FAQ at position fifteen, behind the entire spec
+ * sheet — which is to say behind roughly three thousand words, past the point
+ * where a reader holding either objection has already left. The AGPL one blocks
+ * the highest-value visitor of all: the person evaluating a Team licence for a
+ * company.
+ *
+ * Verbatim from `relocatedFaqs`, so there is one copy of each answer and /faq
+ * still lists both.
+ *
+ * No heading and no eyebrow: this is a row in the hero's own rhythm, not a
+ * section competing with it.
+ */
+export default function ObjectionRow() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/resources/js/components/landing/sponsors.tsx b/resources/js/components/landing/sponsors.tsx
index cb66f19..9a34116 100644
--- a/resources/js/components/landing/sponsors.tsx
+++ b/resources/js/components/landing/sponsors.tsx
@@ -21,6 +21,18 @@ const nimbusSvg = `
);
})}
diff --git a/resources/js/data/faqs.ts b/resources/js/data/faqs.ts
index eaf88de..d0d4b87 100644
--- a/resources/js/data/faqs.ts
+++ b/resources/js/data/faqs.ts
@@ -3,14 +3,19 @@ export interface FaqItem {
answer: string;
}
-import { homeFaqs } from '@/data/home-faqs';
+import { homeFaqs, relocatedFaqs } from '@/data/home-faqs';
/**
- * The /faq page. The first eight items are the homepage set; the rest add the
- * depth that does not belong on the homepage.
+ * The /faq page. The first eight items are everything the homepage asks — the
+ * four in its FAQ section plus the four it now asks inline, where they arise —
+ * and the rest add the depth that does not belong on the homepage at all.
+ *
+ * Relocating a question on the homepage must never remove it from here. This
+ * page is the knowledge base; the homepage is objection handling.
*/
export const faqs: FaqItem[] = [
...homeFaqs,
+ ...relocatedFaqs,
{
question: 'Do I need to pay for the AI features?',
answer: 'No, and there is no AI subscription to buy from us. You bring a provider. Thirteen are supported. Some take an API key, some sign in with an account you already have such as GitHub Copilot, ChatGPT or xAI, and three run entirely on your machine: Ollama, llama.cpp and MLX. Keys are stored in the Keychain.',
diff --git a/resources/js/data/home-faqs.ts b/resources/js/data/home-faqs.ts
index 32ddaa6..d054c48 100644
--- a/resources/js/data/home-faqs.ts
+++ b/resources/js/data/home-faqs.ts
@@ -3,28 +3,17 @@ import type { FaqItem } from '@/data/faqs';
/**
* The homepage FAQ is objection handling, not a knowledge base. The longer set
* lives in `faqs.ts` and is served at /faq.
+ *
+ * Four questions, not eight. The four in `relocatedFaqs` below are still asked
+ * on the homepage — they are just asked where they arise instead of in a block
+ * at position fifteen, which is past the point the reader with that objection
+ * has already left.
*/
export const homeFaqs: FaqItem[] = [
- {
- question: 'Is it really free, or free for now?',
- answer: 'Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All 25 databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. A license adds four things: iCloud Sync, a second Mac activation, encrypted connection export, and environment variables in connection fields. Team adds a shared catalog and a shared query library.',
- },
- {
- question: 'Can I use TablePro at work under AGPLv3?',
- answer: 'Yes. AGPL obligations attach to distributing a modified version of the software, not to using it. There is no company-size or revenue restriction. If you use it at work, buying a license is how the next release gets built.',
- },
{
question: 'Where are my passwords stored?',
answer: 'In the macOS Keychain. Connection details live in a plain JSON file with no secrets in it. A connection can also skip storage entirely and resolve its password at connect time from a file, an environment variable, a shell command, 1Password, HashiCorp Vault or AWS Secrets Manager.',
},
- {
- question: 'Can the AI drop a table on production?',
- answer: 'Not without you clicking. Chat has three modes with real tool gates, and fresh installs start in Ask, which is read only. Write tools ask for approval per call. Destructive operations always need a per-call confirmation plus typing the phrase "I understand this is irreversible", and can never be pre-approved. A connection set to Read-Only Safe Mode denies writes regardless of the mode.',
- },
- {
- question: 'Can I move my connections from another client?',
- answer: 'Yes. TablePro imports from TablePlus, Sequel Ace, DBeaver, DataGrip, Beekeeper Studio and Navicat, including passwords, SSH tunnels and SSL settings. The source app does not need to be running, and groups and folders carry over.',
- },
{
question: 'Why do Cassandra and ScyllaDB appear separately when you say 25?',
answer: 'The grid shows 26 tiles because Cassandra and ScyllaDB are two entries in the connection chooser. They share one driver, and so do libSQL and Turso, which is why the driver count is 25.',
@@ -38,3 +27,45 @@ export const homeFaqs: FaqItem[] = [
answer: 'macOS 14 or later and iOS 18 or later today. A native Linux app is being built in Rust with GTK4, with PostgreSQL, MySQL, SQLite and SQL Server working already, but it is not ready for a beta and there is nothing to install yet. There is no Windows version.',
},
];
+
+/**
+ * Rendered inline on the homepage rather than in the FAQ section, and still
+ * listed in full on /faq.
+ *
+ * - The free/AGPL pair is the strip directly under the hero. Both are hard
+ * blockers, and the AGPL one blocks the highest-value visitor of all: the
+ * person who would buy a Team licence.
+ * - The AI question is answered inside the Agents section, which is the section
+ * that raises the fear in the first place.
+ * - Connection import is answered by the whole SwitchFrom section, which now
+ * sits at position seven rather than eleven.
+ */
+export const relocatedFaqs: FaqItem[] = [
+ {
+ question: 'Is it really free, or free for now?',
+ answer: 'Free, permanently. TablePro is AGPLv3 and the whole app works without a license, with no trial countdown and no per-Mac limit. All 25 databases, the SQL editor, the data grid, the AI assistant, the MCP server, Safe Mode with Touch ID, SSH tunnels, ER diagrams and XLSX export cost nothing. A license adds four things: iCloud Sync, a second Mac activation, encrypted connection export, and environment variables in connection fields. Team adds a shared catalog and a shared query library.',
+ },
+ {
+ question: 'Can I use TablePro at work under AGPLv3?',
+ answer: 'Yes. AGPL obligations attach to distributing a modified version of the software, not to using it. There is no company-size or revenue restriction. If you use it at work, buying a license is how the next release gets built.',
+ },
+ {
+ question: 'Can the AI drop a table on production?',
+ answer: 'Not without you clicking. Chat has three modes with real tool gates, and fresh installs start in Ask, which is read only. Write tools ask for approval per call. Destructive operations always need a per-call confirmation plus typing the phrase "I understand this is irreversible", and can never be pre-approved. A connection set to Read-Only Safe Mode denies writes regardless of the mode.',
+ },
+ {
+ question: 'Can I move my connections from another client?',
+ answer: 'Yes. TablePro imports from TablePlus, Sequel Ace, DBeaver, DataGrip, Beekeeper Studio and Navicat, including passwords, SSH tunnels and SSL settings. The source app does not need to be running, and groups and folders carry over.',
+ },
+];
+
+/** Lookup by question, for the components that render one of these inline. */
+export function relocatedFaq(question: string): FaqItem {
+ const found = relocatedFaqs.find((faq) => faq.question === question);
+
+ if (!found) {
+ throw new Error(`Unknown relocated FAQ: ${question}`);
+ }
+
+ return found;
+}
diff --git a/resources/js/pages/Blog/Post.tsx b/resources/js/pages/Blog/Post.tsx
index f9effd9..0c20168 100644
--- a/resources/js/pages/Blog/Post.tsx
+++ b/resources/js/pages/Blog/Post.tsx
@@ -6,6 +6,7 @@ import SEOHead from '@/components/seo/seo-head';
import { Link } from '@inertiajs/react';
import SectionLabel from '@/components/ui/section-label';
import { FullLine } from '@/components/ui/full-line';
+import Button from '@/components/ui/button';
interface PostFull {
slug: string;
@@ -201,13 +202,12 @@ export default function BlogPost({ post, relatedPosts, downloadUrls, githubStars
Free, open source. macOS 14+. Apple Silicon and Intel.
+ {/*
+ * /faq rendered no h1 at all — SectionShell emits h2, so the page
+ * started its heading tree at level 2. sr-only rather than visible
+ * because the FAQ section already opens with its own headline, and
+ * two stacked headlines saying the same thing is worse than none.
+ */}
+
Frequently asked questions about TablePro
+
);
}
diff --git a/resources/js/pages/Home.tsx b/resources/js/pages/Home.tsx
index 6716cbf..76be67c 100644
--- a/resources/js/pages/Home.tsx
+++ b/resources/js/pages/Home.tsx
@@ -9,6 +9,7 @@ import Workbench from '@/components/landing/workbench';
import Architecture from '@/components/landing/architecture';
import AgentsMcp from '@/components/landing/agents-mcp';
import Mobile from '@/components/landing/mobile';
+import ObjectionRow from '@/components/landing/objection-row';
import Safety from '@/components/landing/safety';
import DepthGrid from '@/components/landing/depth-grid';
import DownloadRail from '@/components/landing/download-rail';
@@ -129,6 +130,14 @@ export default function Home({
{/* Sponsors sit high on purpose: the visible credit is what attracts the next one. */}
+
+ {/*
+ * The two questions that stop a download, answered while the
+ * reader is still deciding whether to keep scrolling rather than
+ * at position fifteen.
+ */}
+
+
{/*
diff --git a/scripts/measure-cold-start.sh b/scripts/measure-cold-start.sh
new file mode 100755
index 0000000..f2f386d
--- /dev/null
+++ b/scripts/measure-cold-start.sh
@@ -0,0 +1,127 @@
+#!/usr/bin/env bash
+#
+# Measures cold start for TablePro and any competitor you name, and prints a
+# record you can paste onto the site.
+#
+# The point of this script is not the number. It is that anyone can re-run it
+# and get their own number, which is the difference between a claim and a
+# measurement. Whatever ends up on the page must link here.
+#
+# WHAT IT MEASURES
+# Wall-clock from `open -a ` returning to the app's first window existing
+# in the accessibility tree. That is "launch to first window" — the moment the
+# user has something to look at. It deliberately does NOT include connecting
+# to a database or rendering a result, because those depend on your network
+# and your server, not on the client.
+#
+# WHY IT NEEDS SUDO
+# A cold start is only cold if the binary and its frameworks are not already
+# in the page cache. `purge` evicts them. Without it you are measuring a warm
+# start, which is a different and much smaller number — and quoting a warm
+# number as a cold one is the kind of thing this whole exercise exists to
+# avoid. The script refuses to report if it could not purge.
+#
+# FIRST RUN
+# macOS will ask for Accessibility permission for your terminal, because
+# reading another app's window list needs it. Grant it, then run again.
+#
+# USAGE
+# ./scripts/measure-cold-start.sh TablePro DBeaver "Beekeeper Studio"
+#
+set -euo pipefail
+
+RUNS="${RUNS:-5}"
+SETTLE="${SETTLE:-3}"
+TIMEOUT="${TIMEOUT:-60}"
+
+if [ "$#" -eq 0 ]; then
+ echo "usage: $0 [ ...]" >&2
+ echo "example: $0 TablePro DBeaver 'Beekeeper Studio'" >&2
+ exit 64
+fi
+
+have_window() {
+ osascript -e "tell application \"System Events\" to exists (window 1 of process \"$1\")" 2>/dev/null
+}
+
+quit_app() {
+ osascript -e "tell application \"$1\" to quit" >/dev/null 2>&1 || true
+ for _ in $(seq 1 40); do
+ pgrep -x "$1" >/dev/null 2>&1 || return 0
+ sleep 0.25
+ done
+ pkill -x "$1" >/dev/null 2>&1 || true
+}
+
+# One measurement. Echoes seconds with millisecond resolution, or "timeout".
+measure_once() {
+ local app="$1" start elapsed
+
+ quit_app "$app"
+ sync
+ sudo purge
+ sleep "$SETTLE"
+
+ start=$(python3 -c 'import time; print(time.monotonic())')
+ open -a "$app"
+
+ while :; do
+ elapsed=$(python3 -c "import time; print(time.monotonic() - $start)")
+ if [ "$(have_window "$app")" = "true" ]; then
+ printf '%.3f' "$elapsed"
+ return 0
+ fi
+ if python3 -c "import sys; sys.exit(0 if $elapsed > $TIMEOUT else 1)"; then
+ printf 'timeout'
+ return 0
+ fi
+ done
+}
+
+echo "Asking for sudo once, so 'purge' can run between every measurement." >&2
+sudo -v
+if ! sudo purge 2>/dev/null; then
+ echo "error: 'purge' failed. Without it these are warm starts, not cold ones." >&2
+ exit 1
+fi
+
+echo
+echo "TablePro cold start — launch to first window"
+echo "date $(date -u '+%Y-%m-%d')"
+echo "hardware $(sysctl -n machdep.cpu.brand_string), $(($(sysctl -n hw.memsize) / 1073741824)) GB"
+echo "macOS $(sw_vers -productVersion) ($(sw_vers -buildVersion))"
+echo "runs per app $RUNS, page cache purged before each"
+echo "method scripts/measure-cold-start.sh in the site repository"
+echo
+
+for app in "$@"; do
+ version=$(defaults read "/Applications/${app}.app/Contents/Info" CFBundleShortVersionString 2>/dev/null || echo "unknown")
+ samples=()
+
+ for _ in $(seq 1 "$RUNS"); do
+ samples+=("$(measure_once "$app")")
+ done
+
+ quit_app "$app"
+
+ printf '%-24s %-12s %s\n' "$app" "$version" "$(
+ printf '%s\n' "${samples[@]}" | python3 -c '
+import statistics, sys
+
+values = [line.strip() for line in sys.stdin if line.strip()]
+numeric = [float(v) for v in values if v != "timeout"]
+
+if not numeric:
+ print("all runs timed out")
+elif len(numeric) < len(values):
+ print(f"{len(values) - len(numeric)} of {len(values)} timed out")
+else:
+ print(f"median {statistics.median(numeric):.2f}s "
+ f"min {min(numeric):.2f}s max {max(numeric):.2f}s")
+'
+ )"
+done
+
+echo
+echo "Report the median. Quote the range too — a number without one invites the"
+echo "reader to assume it is a best case."