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) {

{note}

- +
diff --git a/resources/js/components/landing/header.tsx b/resources/js/components/landing/header.tsx index 3c22fa1..0db741b 100644 --- a/resources/js/components/landing/header.tsx +++ b/resources/js/components/landing/header.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { buttonClasses } from '@/components/ui/button'; import MobileNav from './mobile-nav'; interface Props { @@ -132,7 +133,7 @@ export default function Header({ githubStars }: Props) { type="button" onClick={toggleDropdown} onKeyDown={handleTriggerKeyDown} - className="inline-flex items-center gap-2 rounded-full bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90" + className={buttonClasses('primary', 'sm', 'px-4 py-2 font-medium')} aria-expanded={downloadOpen} aria-haspopup="menu" > diff --git a/resources/js/components/landing/mobile-nav.tsx b/resources/js/components/landing/mobile-nav.tsx index bc2889a..44818b1 100644 --- a/resources/js/components/landing/mobile-nav.tsx +++ b/resources/js/components/landing/mobile-nav.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; +import Button from '@/components/ui/button'; interface Props { isOpen: boolean; @@ -155,13 +156,9 @@ export default function MobileNav({ isOpen, onClose }: Props) {
- + = { lg: 'px-6 py-3 text-base', }; +/** + * The class list, exported because one caller cannot use the component to get + * it: Download.tsx detects the visitor's architecture after mount and swaps + * which of its two buttons is primary by assigning `className` directly. That + * used to hard-code the strings, so the imperative half and the rendered half + * would drift apart the first time either changed. + */ +export function buttonClasses(variant: Variant = 'primary', size: Size = 'md', className?: string): string { + return cn( + 'inline-flex items-center justify-center gap-2 rounded-full font-semibold', + 'transition-opacity duration-(--dur-tap) ease-(--ease-feedback)', + variants[variant], + sizes[size], + className, + ); +} + interface ButtonProps { variant?: Variant; size?: Size; href?: string; + /** + * Forwarded to the rendered element. Download.tsx resolves the real asset + * URL after mount and assigns `.href` imperatively, so a button primitive + * that swallowed the ref would leave those links pointing at the generic + * releases page — a broken download that no test here would catch. + * + * React 19 passes `ref` to function components as an ordinary prop, so this + * needs no forwardRef. + */ + ref?: Ref; target?: string; rel?: string; onClick?: () => void; @@ -47,6 +74,7 @@ export default function Button({ variant = 'primary', size = 'md', href, + ref, target, rel, onClick, @@ -55,18 +83,11 @@ export default function Button({ className, children, }: ButtonProps) { - const classes = cn( - 'inline-flex items-center justify-center gap-2 rounded-full font-semibold', - 'transition-opacity duration-(--dur-tap) ease-(--ease-feedback)', - variants[variant], - sizes[size], - disabled && 'pointer-events-none opacity-50', - className, - ); + const classes = buttonClasses(variant, size, cn(disabled && 'pointer-events-none opacity-50', className)); if (href) { return ( - + {children} ); diff --git a/resources/js/pages/Download.tsx b/resources/js/pages/Download.tsx index 10b7ff8..c256283 100644 --- a/resources/js/pages/Download.tsx +++ b/resources/js/pages/Download.tsx @@ -6,6 +6,7 @@ import Container from '@/components/ui/container'; import SEOHead from '@/components/seo/seo-head'; import SectionLabel from '@/components/ui/section-label'; import { FullLine } from '@/components/ui/full-line'; +import Button, { buttonClasses } from '@/components/ui/button'; interface Props { downloadUrls: { arm64: string; x86_64: string }; @@ -82,8 +83,8 @@ export default function Download({ downloadUrls, githubStars }: Props) { const primary = arch === 'arm64' ? arm64Ref.current : x86Ref.current; const secondary = arch === 'arm64' ? x86Ref.current : arm64Ref.current; if (primary && secondary) { - primary.className = 'inline-flex items-center gap-2 rounded-full bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90'; - secondary.className = 'inline-flex items-center gap-2 rounded-full border border-rule px-6 py-3 text-sm font-semibold text-foreground transition-colors'; + primary.className = buttonClasses('primary'); + secondary.className = buttonClasses('secondary'); } setTimeout(() => { @@ -160,22 +161,20 @@ export default function Download({ downloadUrls, githubStars }: 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() {

{item.title}

{item.body}

-

- {item.spec} -

))}
diff --git a/resources/js/components/landing/footer-cta.tsx b/resources/js/components/landing/footer-cta.tsx index eb3e648..8de7a90 100644 --- a/resources/js/components/landing/footer-cta.tsx +++ b/resources/js/components/landing/footer-cta.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef, useState } from 'react'; import { Check, Copy } from 'lucide-react'; import { toast } from 'sonner'; +import { buttonClasses } from '@/components/ui/button'; import Container from '@/components/ui/container'; import { FullLine } from '@/components/ui/full-line'; import { cellBorders, GridCell, type ColumnMap } from '@/components/ui/grid-cell'; import SectionShell from '@/components/ui/section-shell'; +import Button from '@/components/ui/button'; interface FlashMessage { type: 'success' | 'warning' | 'error'; @@ -96,8 +98,7 @@ function useEmailForm(endpoint: string) { */ const INPUT_CLASS = 'min-w-0 flex-1 rounded-lg border border-rule bg-transparent px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground disabled:opacity-50'; -const SUBMIT_CLASS = - 'shrink-0 rounded-full bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50'; +const SUBMIT_CLASS = buttonClasses('primary', 'sm', 'shrink-0 disabled:opacity-50'); export default function FooterCTA() { const [showBeta, setShowBeta] = useState(false); @@ -220,20 +221,19 @@ export default function FooterCTA() {

- Download for Mac - + diff --git a/resources/js/components/landing/hero.tsx b/resources/js/components/landing/hero.tsx index 2974f91..45c9b65 100644 --- a/resources/js/components/landing/hero.tsx +++ b/resources/js/components/landing/hero.tsx @@ -3,6 +3,7 @@ import Container from '@/components/ui/container'; import { AccentLine, FullLine } from '@/components/ui/full-line'; import ThemedImage from '@/components/ui/themed-image'; import SectionLabel from '@/components/ui/section-label'; +import Button from '@/components/ui/button'; interface Props { githubStars?: number | null; @@ -74,27 +75,25 @@ export default function Hero({ githubStars, latestRelease }: Props) {

- Download for Mac - - + 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 ( +
+ + +
+ {QUESTIONS.map((question, i) => { + const faq = relocatedFaq(question); + + return ( +
+

{faq.question}

+

{faq.answer}

+
+ ); + })} +
+
+ +
+ ); +} 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 = ` + {cell.sponsor.description} + + )} ); })} 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.

diff --git a/resources/js/pages/Compare.tsx b/resources/js/pages/Compare.tsx index 5a43651..07544e1 100644 --- a/resources/js/pages/Compare.tsx +++ b/resources/js/pages/Compare.tsx @@ -6,6 +6,7 @@ import SEOHead from '@/components/seo/seo-head'; import { getComparisonBySlug } from '@/data/comparisons'; import SectionLabel from '@/components/ui/section-label'; import { FullLine } from '@/components/ui/full-line'; +import Button from '@/components/ui/button'; interface Props { slug: string; @@ -533,13 +534,12 @@ export default function Compare({ slug, downloadUrls, githubStars }: Props) { Free and open-source. No account required.

diff --git a/resources/js/pages/DatabaseClient.tsx b/resources/js/pages/DatabaseClient.tsx index 442148f..c9897b2 100644 --- a/resources/js/pages/DatabaseClient.tsx +++ b/resources/js/pages/DatabaseClient.tsx @@ -6,6 +6,7 @@ import SEOHead from '@/components/seo/seo-head'; import { getDatabaseBySlug } from '@/data/databases'; import SectionLabel from '@/components/ui/section-label'; import { FullLine } from '@/components/ui/full-line'; +import Button from '@/components/ui/button'; interface Props { slug: string; @@ -133,19 +134,17 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop @@ -535,19 +534,17 @@ export default function DatabaseClient({ slug, downloadUrls, githubStars }: Prop Free and open-source. macOS 14+. Apple Silicon and Intel.

diff --git a/resources/js/pages/Faq.tsx b/resources/js/pages/Faq.tsx index c064d17..0022f47 100644 --- a/resources/js/pages/Faq.tsx +++ b/resources/js/pages/Faq.tsx @@ -36,9 +36,14 @@ export default function FaqPage({ downloadUrls, githubStars }: Props) { { name: 'FAQ', path: '/faq' }, ]} /> -
- -
+ {/* + * /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."