diff --git a/app/Http/Controllers/DownloadController.php b/app/Http/Controllers/DownloadController.php index 29682416..a8423d41 100644 --- a/app/Http/Controllers/DownloadController.php +++ b/app/Http/Controllers/DownloadController.php @@ -2,36 +2,36 @@ namespace App\Http\Controllers; -use App\Services\Blog\BlogService; +use App\Services\Content\SiteFacts; +use App\Services\Releases\LinuxRelease; +use App\Services\Releases\LinuxReleaseService; use App\Services\Releases\MacReleaseService; use App\Services\Releases\PlatformCatalog; use App\Support\Content\ContentRepository; -use App\Support\Localization\Locales; -use App\Support\Seo\BlogPosts; -use App\Support\Seo\PageRegistry; +use Carbon\CarbonImmutable; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\File; use Inertia\Inertia; use Inertia\Response; -/** - * The download page, `/download` and `/vi/download` (sitemap §A.1, §E.5). - * - * Everything the page links to outside this site arrives here, as props: the - * DMG URL and size from the release service, the App Store and GitHub - * Releases URLs from `platforms.json`, and the docs, source and licence links - * from `facts.json`. No URL is typed into the page or its copy. - * - * The DMG link is in the server-rendered HTML; nothing starts a download on - * its own. - */ +// Every URL and fact the page shows arrives as a prop from the data files and the release services. Linux is sent only +// once platforms.json says it is released, the same way an unreleased iPhone app sends null. class DownloadController extends Controller { - public function __invoke(ContentRepository $content, MacReleaseService $releases, PlatformCatalog $platforms, BlogService $blog, PageRegistry $registry): Response - { + // Which install method a package format belongs to, so a file row can name its distribution. + private const PACKAGE_METHOD = ['deb' => 'ppa', 'rpm' => 'copr', 'flatpak' => 'flatpak']; + + public function __invoke( + ContentRepository $content, + MacReleaseService $releases, + LinuxReleaseService $linuxReleases, + PlatformCatalog $platforms, + SiteFacts $facts, + ): Response { $locale = App::getLocale(); $release = $releases->latest(); $floor = $platforms->macFloorVersion(); + $linux = $platforms->isReleased('linux') ? $this->linux($platforms, $linuxReleases->latest(), $locale) : null; return Inertia::render('Download', [ 'content' => $content->page('download', $locale), @@ -41,39 +41,18 @@ public function __invoke(ContentRepository $content, MacReleaseService $releases 'homebrewCommand' => $this->string($platforms->destination('mac', 'homebrew')['command'] ?? null), 'homebrewTrails' => $floor !== null && $release->version !== null && version_compare($floor, $release->version, '<'), ], - 'ios' => $this->ios($platforms), + 'ios' => $this->ios($platforms, $facts, $locale), + 'linux' => $linux, 'unreleased' => $platforms->unreleased(), - 'links' => $this->links(), - 'featuredEngines' => $this->featuredEngines(), - 'latestPost' => $this->latestPost($blog, $registry, $locale), + 'links' => $this->links($facts), + 'featuredEngines' => $facts->featuredEngineNames(), ]); } /** - * The newest release post whose URL is a page, in the reader's language - * when it is written in it. - * - * @return array{slug: string, locale: string, kind: string, title: string, description: string, date: string, dateFormatted: string, url: string}|null + * @return array{deviceNames: list, requirements: array{systems: list, minVersion: string, displayVersion: string, releaseName: string|null}, appStoreUrl: string|null, free: bool, inAppPurchases: bool, version: string|null, publishedAtFormatted: string|null}|null */ - private function latestPost(BlogService $blog, PageRegistry $registry, string $locale): ?array - { - foreach ($blog->all(Locales::default()) as $post) { - $post = $blog->find($post->slug, $locale) ?? $post; - - if ($post->release !== null && ($registry->find(BlogPosts::ROUTE, ['slug' => $post->slug])?->renders($post->locale) ?? false)) { - return $post->summary($locale); - } - } - - return null; - } - - /** - * The iPhone and iPad card's facts, or null while that app is not released. - * - * @return array{deviceNames: list, requirements: array{systems: list, minVersion: string, displayVersion: string, releaseName: string|null}, appStoreUrl: string|null, free: bool, inAppPurchases: bool}|null - */ - private function ios(PlatformCatalog $platforms): ?array + private function ios(PlatformCatalog $platforms, SiteFacts $facts, string $locale): ?array { $summary = $platforms->summary('ios'); @@ -81,86 +60,129 @@ private function ios(PlatformCatalog $platforms): ?array return null; } - $price = $platforms->find('ios')['price'] ?? []; + $platform = $platforms->find('ios') ?? []; + $price = is_array($platform['price'] ?? null) ? $platform['price'] : []; + $release = is_array($platform['release'] ?? null) ? $platform['release'] : []; return [ ...$summary, - 'appStoreUrl' => $this->url($platforms->destination('ios', 'app-store')['url'] ?? null) ?? $this->url($this->facts()['appStore'] ?? null), - 'free' => is_array($price) && is_numeric($price['amount'] ?? null) && (float) $price['amount'] === 0.0, - 'inAppPurchases' => is_array($price) && ($price['inAppPurchases'] ?? false) === true, + 'appStoreUrl' => $this->url($platforms->destination('ios', 'app-store')['url'] ?? null) ?? $facts->links()['appStore'], + 'free' => is_numeric($price['amount'] ?? null) && (float) $price['amount'] === 0.0, + 'inAppPurchases' => ($price['inAppPurchases'] ?? false) === true, + 'version' => $this->string($release['version'] ?? null), + 'publishedAtFormatted' => $this->date($release['publishedAt'] ?? null, $locale), ]; } /** - * The outbound links the page shows or states in its structured data, each - * null when the data does not name it. - * - * @return array{docs: string|null, changelog: string|null, source: string|null, license: string|null} + * @return array{deviceNames: list, channel: string, distributions: list, via: string}>, desktop: array{name: string, version: string}|null, architectures: list, methods: list, update: string|null}>, packages: list}>, checksums: array{name: string, url: string|null}|null, verify: string|null, release: array{version: string, publishedOn: string|null, releaseUrl: string, releasesUrl: string}|null} */ - private function links(): array + private function linux(PlatformCatalog $platforms, ?LinuxRelease $release, string $locale): array { - $facts = $this->facts(); - $docs = $this->url($facts['docs'] ?? null); + $platform = $platforms->find('linux') ?? []; + $packages = $platforms->linuxPackages(); + $architectures = $this->strings($platform['architectures'] ?? []); + $desktop = $platform['desktop'] ?? null; + + $distributions = []; + + foreach (is_array($platform['distributions'] ?? null) ? $platform['distributions'] : [] as $distribution) { + if (is_array($distribution) && is_string($distribution['id'] ?? null) && is_string($distribution['via'] ?? null)) { + $distributions[] = [ + 'id' => $distribution['id'], + 'name' => $this->string($distribution['name'] ?? null), + 'versions' => $this->strings($distribution['versions'] ?? []), + 'via' => $distribution['via'], + ]; + } + } + + $methods = []; + + foreach (is_array($platform['destinations'] ?? null) ? $platform['destinations'] : [] as $destination) { + $commands = $this->strings(is_array($destination) ? ($destination['commands'] ?? []) : []); + + if ($commands !== [] && in_array($destination['kind'] ?? null, self::PACKAGE_METHOD, true)) { + $methods[] = ['kind' => $destination['kind'], 'commands' => $commands, 'update' => $this->string($destination['update'] ?? null)]; + } + } + + $byName = []; + + foreach ($release?->assets ?? [] as $asset) { + $byName[$asset['name']] = ['name' => $asset['name'], 'url' => $asset['url'], 'bytes' => $asset['bytes']]; + } + + $rows = []; + + foreach ($release !== null ? ($packages['assets'] ?? []) : [] as $asset) { + $via = self::PACKAGE_METHOD[$asset['format']] ?? null; + $named = collect($distributions)->first(fn(array $distribution): bool => $distribution['via'] === $via && $distribution['name'] !== null); + + foreach ($asset['series'] === [] ? [null] : $asset['series'] as $series) { + $files = []; + + foreach ($architectures as $architecture) { + $name = strtr($asset['template'], ['{version}' => $release->version, '{series}' => (string) $series, '{arch}' => $asset['arch'][$architecture] ?? $architecture]); + $files[$architecture] = $byName[$name] ?? null; + } + + if (array_filter($files) !== []) { + $rows[] = ['format' => $asset['format'], 'series' => $series, 'distribution' => $named['name'] ?? null, 'files' => $files]; + } + } + } return [ - 'docs' => $docs, - 'changelog' => $this->url($facts['changelog'] ?? null) ?? ($docs !== null ? rtrim($docs, '/') . '/changelog' : null), - 'source' => $this->url($facts['github'] ?? null) ?? 'https://github.com/' . config('services.github.repo'), - 'license' => $this->url($facts['license'] ?? null), + 'deviceNames' => $this->strings($platform['deviceNames'] ?? []), + 'channel' => $platforms->linuxChannel(), + 'distributions' => $distributions, + 'desktop' => is_array($desktop) && is_string($desktop['name'] ?? null) && is_string($desktop['version'] ?? null) ? ['name' => $desktop['name'], 'version' => $desktop['version']] : null, + 'architectures' => $architectures, + 'methods' => $methods, + 'packages' => $rows, + 'checksums' => $packages !== null && is_string($packages['checksums'] ?? null) ? ['name' => $packages['checksums'], 'url' => $release?->checksumsUrl] : null, + 'verify' => $this->string($packages['verify'] ?? null), + 'release' => $release === null ? null : [ + 'version' => $release->version, + 'publishedOn' => $this->date($release->publishedAt, $locale), + 'releaseUrl' => $release->releaseUrl, + 'releasesUrl' => $release->releasesUrl, + ], ]; } /** - * `facts.json` → `links`, or nothing when the file is missing or malformed. - * - * @return array + * @return array{docs: string|null, changelog: string|null, source: string|null, license: string|null, issues: string|null} */ - private function facts(): array + private function links(SiteFacts $facts): array { - $facts = $this->json('facts.json'); + $links = $facts->links(); - return is_array($facts['links'] ?? null) ? $facts['links'] : []; + return [ + 'docs' => $links['docs'], + 'changelog' => $links['changelog'] ?? ($links['docs'] !== null ? rtrim($links['docs'], '/') . '/changelog' : null), + 'source' => $links['github'] ?? 'https://github.com/' . config('services.github.repo'), + 'license' => $links['license'], + 'issues' => $links['issues'], + ]; } - /** - * The names of the featured, published engines in data order, for the - * `{featuredEngines}` slot of the Mac app's description in the page's - * structured data (positioning §6.3). Read here rather than in the page, - * so the page's bundle does not carry all of `engines.json` for six names. - * - * @return list - */ - private function featuredEngines(): array + private function date(mixed $date, string $locale): ?string { - $names = []; - - foreach ($this->json('engines.json') as $engine) { - if (is_array($engine) && ($engine['featured'] ?? false) === true && ($engine['state'] ?? null) === 'published' && is_string($engine['name'] ?? null)) { - $names[] = $engine['name']; - } + if (! is_string($date) || preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) { + return null; } - return $names; + return CarbonImmutable::createFromFormat('!Y-m-d', $date)?->locale($locale)->isoFormat('LL') ?: null; } /** - * A file in `resources/data`, decoded, or an empty array when it is missing - * or malformed: the page degrades instead of failing. Each file's `Data` - * test is what keeps it valid. - * - * @return array + * @return list */ - private function json(string $file): array + private function strings(mixed $values): array { - $path = resource_path('data/' . $file); - - if (! File::isFile($path)) { - return []; - } - - $decoded = json_decode((string) File::get($path), true); - - return is_array($decoded) ? $decoded : []; + return is_array($values) ? array_values(array_filter($values, fn(mixed $value): bool => is_string($value) && $value !== '')) : []; } private function url(mixed $value): ?string diff --git a/docs/rebuild/design/architecture.md b/docs/rebuild/design/architecture.md index a0de81ed..0df615cb 100644 --- a/docs/rebuild/design/architecture.md +++ b/docs/rebuild/design/architecture.md @@ -1089,17 +1089,14 @@ release state. Its tests use `Http::fake`. | Function | Rule | |---|---| -| `classifyDevice(ua, maxTouchPoints)` | `ios` when `/iPhone\|iPad\|iPod/` matches, or when `Macintosh` matches and `maxTouchPoints > 1` (iPadOS desktop UA). `mac` when `Macintosh` matches. Otherwise `other`. **An iPhone UA ("like Mac OS X") is never `mac`**; the old page treated it as one | -| `archHint()` | Chromium `navigator.userAgentData.getHighEntropyValues(['architecture'])`: `arm` gives `arm64`, `x86` gives `x86_64`, anything else `null`. Safari and Firefox give `null`, and the page then never guesses | - -**Behaviour.** SSR renders both Mac buttons with their real DMG URLs from the server props, plus the Homebrew card -(`brew install --cask tablepro`, with a note that Homebrew can lag a release; the cask version is not shown) and the -App Store card. After mount, an `ios` device swaps card order so the App Store card leads, and the Mac card says -"Download on your Mac". On a `mac` device with a hint, the matching button gets the primary variant (same -dimensions, so no layout shift) plus "Detected: Apple silicon". With no hint, both buttons stay equal under an "About -This Mac → Chip" help line. **Nothing starts on its own**, and no copy says a download is starting. After a click the -page may say "If the download didn't begin, use this link". `trackDownload(location, platform)` is unchanged. No -Windows or Linux buttons appear. +| `classifyDevice(ua, maxTouchPoints)` | `ios` when `/iPhone\|iPad\|iPod/` matches, or when `Macintosh` matches and `maxTouchPoints > 1` (iPadOS desktop UA). `mac` when `Macintosh` matches. `other` for Android and ChromeOS, `linux` for any other `Linux` or `X11`. Otherwise `other`. **An iPhone UA ("like Mac OS X") is never `mac`**. The root template's head script repeats the `ios` and `linux` rules as classes, before first paint (`tests/js/device.test.ts` runs it against this function) | + +**Behaviour.** SSR renders one panel per released platform with real URLs from the server props (the DMG, the App +Store, the Linux commands and files) and shows the Mac panel. Before paint, the head script's `ios` or `linux` class +shows that platform's panel instead; after mount `classifyDevice()` keeps the same choice, or the one a `#mac`, `#ios` +or `#linux` link names (design-system §8.7). **Nothing starts on its own**, and no copy says a download is starting. +After a click the page may say "If the download didn't begin, use this link". `trackDownload(location, platform)` takes +`mac`, `ios` or `linux`. No Windows button appears. ### 1.14 Forms and cross-app contracts @@ -1109,7 +1106,7 @@ Windows or Linux buttons appear. | `POST /discount/preview {code}` | Unchanged. The invalid-coupon toast text comes from the catalog | | `POST /newsletter/subscribe {email}` | Adds `locale`. Fallback strings in `use-email-form.ts:40-56` move to the `forms` namespace. 429 always uses the catalog, because the platform's 429 text is shared with the license API and stays English | | `GET /api/newsletter/stats` | **The public site no longer calls it.** The new footer shows no subscriber count (sitemap §B.3, design-system §11.1, item 6), so `footer.tsx:122`'s lazy fetch goes. The endpoint stays as a platform contract. `Contracts/PublicContractsTest` asserts that no file in `resources/js` fetches it | -| GA events `download_click{location, platform}`, `checkout_started{tier, cycle}`, `newsletter_signup_clicked{source}` | Names and parameters unchanged. The new layout may add new `location` values; `platform` stays `mac\|ios` | +| GA events `download_click{location, platform}`, `checkout_started{tier, cycle}`, `newsletter_signup_clicked{source}` | Names and parameters unchanged. The new layout may add new `location` values; `platform` is `mac\|ios\|linux` | | Storage keys `tablepro:analytics-consent`, `theme`, `tablepro:attribution` | Unchanged. `theme` now has writers (§1.10). `tablepro:banner-dismissed` is retired: the banner cannot be closed (2026-10-22) | | Consent head order | Unchanged (pinned) | | Inbound URLs `/?ref=…#pricing`, `/download`, `/account`, `/privacy` | Unchanged (§1.1) | diff --git a/docs/rebuild/design/design-system.md b/docs/rebuild/design/design-system.md index 923aa2b2..ad632d74 100644 --- a/docs/rebuild/design/design-system.md +++ b/docs/rebuild/design/design-system.md @@ -506,7 +506,7 @@ Decided by the owner on 2026-10-06, after a rendered survey of grid-line sites ( - the homepage: the hero's two download actions, the featured engines, the sponsors, the workflow rows (from 1280px each sets its text in 4 columns beside its capture in 8, so one divider runs down the section; a detail row is two cells, 5 / 7 from 1024), the safety row, the platform block, the plans and the closing actions - `/pricing`: the plans (`PricingPlans` is the same component) -- `/download`: the Mac and iPhone and iPad platforms +- `/download`: the release row - `/faq`: one row per topic, the topic beside its questions - a feature page: every block whose crop sits beside its text (5 / 7), and the documentation and related-pages row @@ -517,7 +517,7 @@ Prose sections (`#ai`, `#switch`, articles) stay unlined inside their joins. Car - `frame-table` on a DataTable's scroll region: the region reaches the rails, the first and last cells pad their text back to the content edges (the first column's text now starts on the heading edge), and the header row has one rule, not the 2px of its own rule plus the first row's. - `frame-rows` on a ruled list in a wide section, and `frame-rows-text` on one at the reading measure: the list reaches the rails from whatever measure it sits in, using container units (`Section`'s Container is a size container), and each row keeps its text at the measure. Used by DescriptionList rows, FaqList items, PostList rows and the feature and comparison lists. - `cell-rows` on a ruled list inside a cell, and the same bleed on a single divider in a cell (a plan's "Includes", the Mac card's "Other ways to install"): wall to wall. -- Topics within one subject are blocks of their own, so the frame's joins separate them: `/pricing`'s license, billing, refunds, Team, open source and FAQ, `/download`'s install, updates and older versions, and a blog post's related posts. +- Topics within one subject are blocks of their own, so the frame's joins separate them: `/pricing`'s license, billing, refunds, Team, open source and FAQ, `/download`'s release row, and a blog post's related posts. - A list that opens a block starts on its join (the blog index), and a list inside a card keeps its own inset rules, closed by the card's edge. `PageFrameTest` fails any `dl`, ruled list or table in `
` that does none of these, outside a card or a blog article's own prose. @@ -556,7 +556,7 @@ Repo key: | `FrameRails` | P | **New** | — | The page frame's rails (§4.7), over the page and in the header | | `CellGrid` | P | **New** | — | Shared-border cells reaching the rails (§4.7). Wide sections only | | `PageHeader` | P+A | **Replace** | `section-shell.tsx` `PageHeader`; A `screen-header.tsx`, `notice-page.tsx` header | Breadcrumbs, H1, lead, meta line, actions. Variants `marketing` (`h1`), `utility` (`h1`, compact top), `screen` (account: H1 in the `h2` style) | -| `Card` | P+A | **New** (sparingly) | — | Only for a block that holds one object: the download page's platform cards, account license and settings panels, and the database facts card. A set of like items is a `CellGrid` (§4.7), never a grid of cards | +| `Card` | P+A | **New** (sparingly) | — | Only for a block that holds one object: account license and settings panels, and the database facts card. A set of like items is a `CellGrid` (§4.7), never a grid of cards | | `DataTable` | Shared\* | **Refactor** | `ui/data-table.tsx` (both) | Caption required, `visible` or `sr-only`. Scroll region, priority columns, numeric alignment. No row-selection bar | | `DescriptionList` | Shared\* | **Replace** | `ui/ledger.tsx` (both) | `
` key/value rows: database facts, license details | | `Badge` | Shared\* | **Refactor** | A `ui/badge.tsx` | Variants `neutral`, `accent`, `outline`. 13px/500, sentence case | @@ -912,9 +912,9 @@ One shared file, `components/shared/theme-control.tsx` (architecture §1.10 and - Mac first in the markup on every device. On an iPhone or iPad the badge is drawn first (CSS `order` under the `ios` class the root template sets before first paint), so the actions never swap after the page is shown. - **DownloadBand:** an H2 + one sentence + PlatformActions, at `wide` width, on `--background`. -**PlatformCard** (download page, the homepage's `#platforms`): +**PlatformCard** (the homepage's `#platforms`; the download page uses platform tabs, §8.7): -- A cell of a `CellGrid` (§4.7), on the download page and in the homepage's `#platforms`, with: +- A cell of a `CellGrid` (§4.7), in the homepage's `#platforms`, with: - platform name (`h2` semantic, `h3` style) - a version Badge `neutral` "v0.77.0 · 2 Oct 2026" (from the release service, locale-formatted) - the requirement line @@ -1561,37 +1561,38 @@ The `#features` heading and the intro say "adds" / "bổ sung", never "unlock" ( ### 8.7 Download (`/download`) -Sections and ids follow sitemap §A.1. The `#mac` card holds the other ways to install, which is where positioning §3.2's "Other ways to install" link lands. +Rebuilt on 2026-11-07 as platform tabs, after tailscale.com/download (owner: the three-card page was too much and hard +to use). Linux renders only once `platforms.json` says it is released; with two platforms the switch has two segments. ``` -PageHeader (utility) H1 · current release: v{ver} · {date} · Release notes ↗ (never "your download is starting") -┌ PlatformCard Mac #mac · cols 1–7 ──────────────────┐ ┌ PlatformCard iPhone & iPad #ios · cols 8–12 ┐ -│ H2 Mac Badge v{ver} · {date} │ │ H2 iPhone and iPad │ -│ Requires macOS 13 Ventura or later │ │ Requires iOS and iPadOS 18 or later │ -│ [Download for Apple silicon] lg primary │ │ [ App Store badge ] │ -│ [Download for Intel] lg secondary │ │ Free · no in-app purchases │ -│ ▸ Which Mac do I have? (Disclosure) │ │ TablePro for iPhone and iPad → │ -│ ── Other ways to install (h3 style) ── │ │ │ -│ Homebrew CodeBlock(command) brew install --cask … ⧉ │ │ │ -│ (no version beside the command) │ │ │ -│ Callout warning while the cask lags (from data) │ │ │ -│ GitHub Releases ↗ · Build from source (AGPLv3) ↗ │ │ │ -└──────────────────────────────────────────────────────┘ └───────────────────────────────────────────────┘ -The two platforms are cells of one CellGrid between two joins, so neither draws a box; the Mac cell's "Other ways to install" -rule runs from wall to wall. Each topic below is a Section of its own at `text` width, behind a join (§4.7). -#install Install and first run: ordered list (drag to Applications; notarized; drivers download the first time - you pick an engine, which needs a network; the sample database) -#updates Daily check; the Settings toggle -#other-platforms Windows and Linux are not available; no date -#older-versions Older versions and release notes +header the app icon (72px) and the H1, centred. No lead. The only centred page header (owner, 2026-11-07) +switch SegmentedControl size lg, centred at 40rem: Mac | iPhone & iPad | Linux (Beta), icon above label +panel #mac [Download for Mac] primary lg · version, date · size · requirement · architectures · updates line + Homebrew: CodeBlock with copy, the trailing-cask note · Verify your download (Disclosure: signed and + notarized, shasum, the SHA-256) +panel #ios App Store badge · version, date · free · requirement · updates line · /ios → +panel #linux version, date · what beta means (GitHub issues) · tabs Ubuntu | Fedora | Flatpak | Files, each one + CodeBlock of platforms.json's commands, its requirement and update lines; Files: packages by format × + architecture, SHA256SUMS, Verify a file +release row CellGrid: Release notes (Mac, Linux, Changelog) #release-notes | Older versions (all releases, build + from source) #older-versions | Is there a version for Windows? #other-platforms ``` -- **No screenshot**, as on the reference download pages. +The page holds downloads only (owner, 2026-11-07): no feature table, no engine counts, no screenshots. What runs where +lives on the feature pages' "Where it works" tables and /databases. Each fact appears once: a panel carries its +platform's version, requirement and update line, and the release row only links. + +- One panel shows at a time. The server renders the Mac panel; every panel is in the markup, hidden by the `hidden` + class (preflight's `[hidden]` is `!important`, so the attribute could not be overridden before paint). +- Before paint, the root template's `ios` or `linux` class shows that platform's panel and marks its segment (app.css), + so an iPhone or a Linux desktop never sees the Mac panel first. After mount `classifyDevice()` takes over with the + same answer: no swap, no layout shift. +- `#mac`, `#ios` and `#linux` open their panel; a choice is written to the URL hash with `replaceState`. `#updates` + (Security's "Download and updates") lands on the switch. +- The segments' thumb slides only after the reader chose; a value restored on load lands in place. The chosen Linux tab + is kept in `localStorage` (`tablepro:linux-install`). - **No auto-download**, and an iPhone user agent is never detected as a Mac. -- Both arch links are server-rendered. The UA-CH hint only swaps which of the two is `primary`; the sizes are identical, so there is no CLS. -- On an iPhone or iPad UA, both Mac buttons become `secondary`, which leaves the App Store badge as the strongest action, again with no layout change. -- On a Mac whose browser gives no hint (Safari, Firefox), both buttons stay equal and "Which Mac do I have?" opens by itself. -- If the release API fails, the links fall back to GitHub `releases/latest` (server-rendered). +- If the release API fails, the Mac button falls back to GitHub `releases/latest` (server-rendered). ### 8.8 iPhone & iPad (`/ios`) diff --git a/resources/css/app.css b/resources/css/app.css index c2abcd42..c39bfdac 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -457,6 +457,37 @@ html.campaign-live .campaign-band { } } +/* + * The download page's platform before React has chosen it (PlatformSwitch): the head script's `ios` or `linux` class + * shows that panel and marks its segment, so an iPhone or a Linux desktop never sees the Mac panel first. Panels hide + * with the `hidden` class, not the attribute, which preflight hides with `!important`. + */ +:is(html.ios [data-platform-switch]:has(> [data-panel='ios']), html.linux [data-platform-switch]:has(> [data-panel='linux'])):not([data-ready]) { + & > [data-panel] { + display: none; + } + + & > fieldset [data-segment] { + border-color: transparent; + background-color: transparent; + box-shadow: none; + color: var(--muted-foreground); + } +} + +/* The same specificity as the rule above, so these win by coming after it. */ +html.ios [data-platform-switch]:has(> [data-panel='ios']):not([data-ready]) > [data-panel='ios'], +html.linux [data-platform-switch]:has(> [data-panel='linux']):not([data-ready]) > [data-panel='linux'] { + display: block; +} + +html.ios [data-platform-switch]:has(> [data-panel='ios']):not([data-ready]) > fieldset [data-segment='ios'], +html.linux [data-platform-switch]:has(> [data-panel='linux']):not([data-ready]) > fieldset [data-segment='linux'] { + border-color: var(--rule); + background-color: var(--segment-selected); + color: var(--foreground); +} + /* A row of the plan table under the pointer, on whichever ground the table sits. */ @media (hover: hover) { .plan-row:hover { diff --git a/resources/data/content/de/download.json b/resources/data/content/de/download.json index dfbf1330..d33ddf66 100644 --- a/resources/data/content/de/download.json +++ b/resources/data/content/de/download.json @@ -8,55 +8,25 @@ "title": "TablePro herunterladen" }, "header": { - "title": "TablePro herunterladen", - "lead": "Wähle einen Build für dein Gerät." + "title": "TablePro herunterladen" }, "mac": { - "otherWays": "Weitere Installationsmöglichkeiten", "homebrew": { "title": "Homebrew", - "body": "Homebrew installiert die Version für den Chip deines Macs. Danach hält sich die App selbst aktuell.", "trails": "Die Homebrew-Version wird separat aktualisiert und kann einer neuen Veröffentlichung hinterherhinken. Falls eine ältere Version installiert wird, wähle TablePro > Check for Updates…, um die aktuelle zu erhalten." }, - "releases": "Alle Builds auf GitHub Releases", + "signing": "TablePro ist mit einer Developer ID signiert und von Apple notarisiert. Du musst daher keine Sicherheitseinstellung ändern, um TablePro zu öffnen.", "source": "Aus dem Quellcode bauen (AGPLv3)" }, "ios": { - "body": "Eine kostenlose App für {devices} mit eigenem Funktionsumfang.", "link": "TablePro für {devices}" }, - "install": { - "title": "Installation und erster Start", - "steps": [ - "Öffne die heruntergeladene .dmg-Datei.", - "Ziehe TablePro in den Ordner Programme.", - "Öffne TablePro aus Programme. Wähle Open, wenn macOS um Bestätigung bittet.", - "Füge eine Verbindung hinzu oder teste die enthaltene Chinook-SQLite-Datenbank über Help > Open Sample Database." - ], - "signing": "TablePro ist mit einer Developer ID signiert und von Apple notarisiert. Du musst daher keine Sicherheitseinstellung ändern, um TablePro zu öffnen.", - "drivers": "Gängige Treiber sind enthalten. Andere benötigen Internet für den ersten Download und werden unabhängig von der App aktualisiert.", - "databases": "Unterstützte Datenbanken", - "guide": { - "label": "Installationsanleitung lesen (Englisch)", - "path": "/installation" - } - }, - "updates": { - "title": "Updates", - "paragraphs": [ - "TablePro sucht einmal täglich nach Updates. Eine neue Version wird im Hintergrund heruntergeladen und beim nächsten Beenden der App installiert.", - "Die meisten Versionen werden über etwa anderthalb Tage schrittweise an Macs ausgeliefert. Um sie früher zu erhalten, wähle TablePro > Check for Updates…", - "Du kannst automatische Prüfungen oder Installationen unter Settings > General deaktivieren. Update-Prüfungen gehen an GitHub, wo die Versionen veröffentlicht werden. Jedes Update ist signiert und wird vor der Installation überprüft." - ] - }, "otherPlatforms": { - "title": "Andere Plattformen", "body": "TablePro ist für {platforms} nicht verfügbar und es gibt keinen Erscheinungstermin.", "faq": "Plattformfragen in den FAQ" }, "olderVersions": { - "title": "Ältere Versionen und Versionshinweise", - "body": "Jede Version mit ihren Hinweisen und Disk-Images findest du auf GitHub Releases. Das Änderungsprotokoll listet die Änderungen jeder Version auf.", + "title": "Ältere Versionen", "releases": "Alle Versionen auf GitHub", "changelog": "Änderungsprotokoll (Englisch)" } diff --git a/resources/data/content/en/download.json b/resources/data/content/en/download.json index e23f265c..175a59c5 100644 --- a/resources/data/content/en/download.json +++ b/resources/data/content/en/download.json @@ -8,55 +8,25 @@ "title": "Download TablePro" }, "header": { - "title": "Download TablePro", - "lead": "Choose a build for your device." + "title": "Download TablePro" }, "mac": { - "otherWays": "Other ways to install", "homebrew": { "title": "Homebrew", - "body": "Homebrew installs the build for your Mac’s chip. After that, the app keeps itself up to date.", "trails": "Homebrew’s copy is updated separately and can trail a new release. If it installs an older version, choose TablePro > Check for Updates… to get the current one." }, - "releases": "Every build on GitHub Releases", + "signing": "TablePro is signed with a Developer ID and notarized by Apple, so you don’t need to change any security setting to open it.", "source": "Build from source (AGPLv3)" }, "ios": { - "body": "A free app for {devices}, with its own set of features.", "link": "TablePro for {devices}" }, - "install": { - "title": "Install and first run", - "steps": [ - "Open the downloaded .dmg file.", - "Drag TablePro into the Applications folder.", - "Open TablePro from Applications. Choose Open when macOS asks you to confirm.", - "Add a connection, or try the bundled Chinook SQLite database through Help > Open Sample Database." - ], - "signing": "TablePro is signed with a Developer ID and notarized by Apple, so you don’t need to change any security setting to open it.", - "drivers": "Common drivers are bundled. Others need an internet connection to download on first use and update separately from the app.", - "databases": "Supported databases", - "guide": { - "label": "Read the installation guide", - "path": "/installation" - } - }, - "updates": { - "title": "Updates", - "paragraphs": [ - "TablePro checks for updates once a day. A new version downloads in the background and installs the next time you quit the app.", - "Most releases reach Macs in stages over about a day and a half. To get one sooner, choose TablePro > Check for Updates…", - "You can turn off automatic checks or automatic installs in Settings > General. Update checks go to GitHub, where releases are published, and every update is signed and verified before it installs." - ] - }, "otherPlatforms": { - "title": "Other platforms", "body": "TablePro is not available for {platforms}, and there is no release date.", "faq": "Platform questions in the FAQ" }, "olderVersions": { - "title": "Older versions and release notes", - "body": "Every release, with its notes and disk images, is on GitHub Releases. The changelog lists what changed in each version.", + "title": "Older versions", "releases": "All releases on GitHub", "changelog": "Changelog" } diff --git a/resources/data/content/es/download.json b/resources/data/content/es/download.json index 249d6c44..12aa8fc9 100644 --- a/resources/data/content/es/download.json +++ b/resources/data/content/es/download.json @@ -8,55 +8,25 @@ "title": "Descargar TablePro" }, "header": { - "title": "Descargar TablePro", - "lead": "Elige la versión para tu dispositivo." + "title": "Descargar TablePro" }, "mac": { - "otherWays": "Otras formas de instalar", "homebrew": { "title": "Homebrew", - "body": "Homebrew instala la versión para el chip de tu Mac. Después, la app se mantiene actualizada por sí sola.", "trails": "La copia de Homebrew se actualiza por separado y puede ir por detrás de una nueva versión. Si instala una versión anterior, selecciona TablePro > Check for Updates… para obtener la actual." }, - "releases": "Todas las compilaciones en GitHub Releases", + "signing": "TablePro está firmado con un Developer ID y notarizado por Apple, así que no necesitas cambiar ningún ajuste de seguridad para abrirlo.", "source": "Compilar desde el código fuente (AGPLv3)" }, "ios": { - "body": "Una app gratuita para {devices}, con sus propias funciones.", "link": "TablePro para {devices}" }, - "install": { - "title": "Instalación y primer inicio", - "steps": [ - "Abre el archivo .dmg descargado.", - "Arrastra TablePro a la carpeta Aplicaciones.", - "Abre TablePro desde Aplicaciones. Elige Open cuando macOS pida confirmación.", - "Añade una conexión o prueba la base SQLite Chinook incluida desde Help > Open Sample Database." - ], - "signing": "TablePro está firmado con un Developer ID y notarizado por Apple, así que no necesitas cambiar ningún ajuste de seguridad para abrirlo.", - "drivers": "Los controladores habituales vienen incluidos. Los demás necesitan internet para la primera descarga y se actualizan por separado de la app.", - "databases": "Bases de datos compatibles", - "guide": { - "label": "Leer la guía de instalación (inglés)", - "path": "/installation" - } - }, - "updates": { - "title": "Actualizaciones", - "paragraphs": [ - "TablePro busca actualizaciones una vez al día. Las nuevas versiones se descargan en segundo plano y se instalan la próxima vez que cierres la app.", - "La mayoría de las versiones llegan a los Mac por fases durante aproximadamente un día y medio. Para obtenerlas antes, selecciona TablePro > Check for Updates…", - "Puedes desactivar las comprobaciones o las instalaciones automáticas en Settings > General. Las comprobaciones se realizan en GitHub, donde se publican las versiones, y cada actualización se firma y verifica antes de instalarse." - ] - }, "otherPlatforms": { - "title": "Otras plataformas", "body": "TablePro no está disponible para {platforms} y no hay fecha de lanzamiento.", "faq": "Preguntas sobre plataformas en las preguntas frecuentes" }, "olderVersions": { - "title": "Versiones anteriores y notas de versión", - "body": "Todas las versiones, con sus notas e imágenes de disco, están en GitHub Releases. El historial de cambios enumera las novedades de cada versión.", + "title": "Versiones anteriores", "releases": "Todas las versiones en GitHub", "changelog": "Historial de cambios (inglés)" } diff --git a/resources/data/content/fr/download.json b/resources/data/content/fr/download.json index d9cf9cfa..8768beb2 100644 --- a/resources/data/content/fr/download.json +++ b/resources/data/content/fr/download.json @@ -8,55 +8,25 @@ "title": "Télécharger TablePro" }, "header": { - "title": "Télécharger TablePro", - "lead": "Choisissez la version pour votre appareil." + "title": "Télécharger TablePro" }, "mac": { - "otherWays": "Autres méthodes d’installation", "homebrew": { "title": "Homebrew", - "body": "Homebrew installe la version adaptée à la puce de votre Mac. L’application se met ensuite à jour automatiquement.", "trails": "La version de Homebrew est mise à jour séparément et peut être en retard sur une nouvelle sortie. Si une ancienne version est installée, choisissez TablePro > Rechercher les mises à jour… (TablePro > Check for Updates…) pour obtenir la version actuelle." }, - "releases": "Toutes les versions sur GitHub Releases", + "signing": "TablePro est signé avec un Developer ID et notarié par Apple. Vous n’avez donc aucun réglage de sécurité à modifier pour l’ouvrir.", "source": "Compiler depuis le code source (AGPLv3)" }, "ios": { - "body": "Une app gratuite pour {devices}, avec ses propres fonctions.", "link": "TablePro pour {devices}" }, - "install": { - "title": "Installation et premier lancement", - "steps": [ - "Ouvrez le fichier .dmg téléchargé.", - "Faites glisser TablePro dans le dossier Applications.", - "Ouvrez TablePro depuis Applications. Choisissez Ouvrir (Open) lorsque macOS demande confirmation.", - "Ajoutez une connexion ou essayez la base SQLite Chinook incluse via Aide > Ouvrir la base de données d’exemple (Help > Open Sample Database)." - ], - "signing": "TablePro est signé avec un Developer ID et notarié par Apple. Vous n’avez donc aucun réglage de sécurité à modifier pour l’ouvrir.", - "drivers": "Les pilotes courants sont inclus. Les autres nécessitent internet pour le premier téléchargement et se mettent à jour séparément de l’app.", - "databases": "Bases de données prises en charge", - "guide": { - "label": "Lire le guide d’installation (anglais)", - "path": "/installation" - } - }, - "updates": { - "title": "Mises à jour", - "paragraphs": [ - "TablePro recherche des mises à jour une fois par jour. Une nouvelle version se télécharge en arrière-plan et s’installe la prochaine fois que vous quittez l’application.", - "La plupart des versions sont distribuées aux Mac par étapes sur environ un jour et demi. Pour les obtenir plus tôt, choisissez TablePro > Rechercher les mises à jour… (TablePro > Check for Updates…)", - "Vous pouvez désactiver les recherches ou les installations automatiques dans Réglages > Général (Settings > General). Les recherches de mises à jour interrogent GitHub, où les versions sont publiées, et chaque mise à jour est signée et vérifiée avant son installation." - ] - }, "otherPlatforms": { - "title": "Autres plateformes", "body": "TablePro n’est pas disponible pour {platforms}, et il n’y a aucune date de sortie.", "faq": "Questions sur les plateformes dans la FAQ" }, "olderVersions": { - "title": "Anciennes versions et notes de version", - "body": "Chaque version, avec ses notes et ses images disque, figure sur GitHub Releases. Le journal des modifications liste les changements de chaque version.", + "title": "Anciennes versions", "releases": "Toutes les versions sur GitHub", "changelog": "Journal des modifications (anglais)" } diff --git a/resources/data/content/id/download.json b/resources/data/content/id/download.json index d6996966..b66a1025 100644 --- a/resources/data/content/id/download.json +++ b/resources/data/content/id/download.json @@ -8,55 +8,25 @@ "title": "Unduh TablePro" }, "header": { - "title": "Unduh TablePro", - "lead": "Pilih build untuk perangkat Anda." + "title": "Unduh TablePro" }, "mac": { - "otherWays": "Cara instalasi lainnya", "homebrew": { "title": "Homebrew", - "body": "Homebrew menginstal build sesuai chip Mac Anda. Setelah itu, aplikasi memperbarui dirinya sendiri.", "trails": "Salinan Homebrew diperbarui secara terpisah dan mungkin tertinggal dari rilis terbaru. Jika versi lama terinstal, pilih TablePro > Check for Updates… untuk mendapatkan versi terbaru." }, - "releases": "Semua build di GitHub Releases", + "signing": "TablePro ditandatangani dengan Developer ID dan dinotarisasi Apple, sehingga Anda tidak perlu mengubah pengaturan keamanan untuk membukanya.", "source": "Build dari kode sumber (AGPLv3)" }, "ios": { - "body": "Aplikasi gratis untuk {devices}, dengan serangkaian fiturnya sendiri.", "link": "TablePro untuk {devices}" }, - "install": { - "title": "Instalasi dan penggunaan pertama", - "steps": [ - "Buka file .dmg yang diunduh.", - "Seret TablePro ke folder Aplikasi.", - "Buka TablePro dari Aplikasi. Pilih Open ketika macOS meminta Anda untuk mengonfirmasi.", - "Tambahkan koneksi, atau coba paket database Chinook SQLite melalui Help > Open Sample Database." - ], - "signing": "TablePro ditandatangani dengan Developer ID dan dinotarisasi Apple, sehingga Anda tidak perlu mengubah pengaturan keamanan untuk membukanya.", - "drivers": "Driver umum dibundel. Yang lain memerlukan koneksi internet untuk mengunduh pada penggunaan pertama dan memperbarui secara terpisah dari aplikasi.", - "databases": "Database yang didukung", - "guide": { - "label": "Baca panduan instalasi (bahasa Inggris)", - "path": "/installation" - } - }, - "updates": { - "title": "Pembaruan", - "paragraphs": [ - "TablePro memeriksa pembaruan sekali sehari. Versi baru diunduh di latar belakang dan diinstal saat Anda menutup aplikasi berikutnya.", - "Sebagian besar rilis menjangkau Mac secara bertahap selama sekitar satu setengah hari. Untuk mendapatkannya lebih awal, pilih TablePro > Check for Updates…", - "Anda dapat menonaktifkan pemeriksaan atau instalasi otomatis di Settings > General. Pemeriksaan pembaruan mengakses GitHub, tempat rilis dipublikasikan, dan setiap pembaruan ditandatangani serta diverifikasi sebelum diinstal." - ] - }, "otherPlatforms": { - "title": "Platform lainnya", "body": "TablePro tidak tersedia untuk {platforms} dan tidak ada tanggal rilis.", "faq": "Pertanyaan tentang platform di halaman Pertanyaan umum" }, "olderVersions": { - "title": "Versi lama dan catatan rilis", - "body": "Setiap rilis beserta catatan dan disk image-nya tersedia di GitHub Releases. Catatan perubahan mencantumkan perubahan dalam setiap versi.", + "title": "Versi lama", "releases": "Semua rilis di GitHub", "changelog": "Catatan perubahan (bahasa Inggris)" } diff --git a/resources/data/content/it/download.json b/resources/data/content/it/download.json index 72ef8042..fd09cb7d 100644 --- a/resources/data/content/it/download.json +++ b/resources/data/content/it/download.json @@ -8,55 +8,25 @@ "title": "Scarica TablePro" }, "header": { - "title": "Scarica TablePro", - "lead": "Scegli una build per il tuo dispositivo." + "title": "Scarica TablePro" }, "mac": { - "otherWays": "Altri modi per installare", "homebrew": { "title": "Homebrew", - "body": "Homebrew installa la build per il chip del tuo Mac. Poi l’app si mantiene aggiornata da sola.", "trails": "La copia di Homebrew viene aggiornata separatamente e può essere in ritardo rispetto a un nuovo rilascio. Se installa una versione precedente, scegli TablePro > Check for Updates… per ottenere quella attuale." }, - "releases": "Tutte le build su GitHub Releases", + "signing": "TablePro è firmato con un Developer ID e autenticato da Apple, quindi non devi modificare alcuna impostazione di sicurezza per aprirlo.", "source": "Compila dal sorgente (AGPLv3)" }, "ios": { - "body": "Un'app gratuita per {devices}, con il proprio set di funzionalità.", "link": "TablePro per {devices}" }, - "install": { - "title": "Installazione e primo avvio", - "steps": [ - "Apri il file .dmg scaricato.", - "Trascina TablePro nella cartella Applicazioni.", - "Apri TablePro da Applicazioni. Scegli Open quando macOS ti chiede di confermare.", - "Aggiungi una connessione o prova il database Chinook SQLite in bundle tramite Help > Open Sample Database." - ], - "signing": "TablePro è firmato con un Developer ID e autenticato da Apple, quindi non devi modificare alcuna impostazione di sicurezza per aprirlo.", - "drivers": "I driver comuni sono raggruppati. Altri necessitano di una connessione Internet per scaricare al primo utilizzo e aggiornare separatamente dall'app.", - "databases": "Database supportati", - "guide": { - "label": "Leggi la guida all’installazione (inglese)", - "path": "/installation" - } - }, - "updates": { - "title": "Aggiornamenti", - "paragraphs": [ - "TablePro controlla gli aggiornamenti una volta al giorno. Una nuova versione viene scaricata in background e installata la prossima volta che chiudi l’app.", - "La maggior parte dei rilasci raggiunge i Mac gradualmente in circa un giorno e mezzo. Per ottenerne uno prima, scegli TablePro > Check for Updates…", - "Puoi disattivare i controlli o le installazioni automatiche in Settings > General. I controlli degli aggiornamenti si rivolgono a GitHub, dove vengono pubblicati i rilasci, e ogni aggiornamento viene firmato e verificato prima dell’installazione." - ] - }, "otherPlatforms": { - "title": "Altre piattaforme", "body": "TablePro non è disponibile per {platforms} e non c’è una data di rilascio.", "faq": "Domande sulle piattaforme nelle FAQ" }, "olderVersions": { - "title": "Versioni precedenti e note di rilascio", - "body": "Ogni rilascio, con le note e le immagini disco, si trova su GitHub Releases. Il registro delle modifiche elenca le novità di ogni versione.", + "title": "Versioni precedenti", "releases": "Tutti i rilasci su GitHub", "changelog": "Registro delle modifiche (inglese)" } diff --git a/resources/data/content/ja/download.json b/resources/data/content/ja/download.json index 2127901d..d57f78bf 100644 --- a/resources/data/content/ja/download.json +++ b/resources/data/content/ja/download.json @@ -8,55 +8,25 @@ "title": "TablePro をダウンロード" }, "header": { - "title": "TablePro をダウンロード", - "lead": "お使いのデバイス向けのビルドを選んでください。" + "title": "TablePro をダウンロード" }, "mac": { - "otherWays": "その他のインストール方法", "homebrew": { "title": "Homebrew", - "body": "Homebrew はお使いの Mac のチップに合うビルドをインストールします。その後はアプリ自身が最新の状態を保ちます。", "trails": "Homebrew の配布版は別途更新されるため、新しいリリースより遅れる場合があります。古いバージョンが入った場合は、TablePro > Check for Updates… を選んで最新バージョンを取得してください。" }, - "releases": "GitHub Releases の全ビルド", + "signing": "TablePro は Developer ID で署名され、Apple の公証を受けています。開くためにセキュリティ設定を変更する必要はありません。", "source": "ソースからビルド(AGPLv3)" }, "ios": { - "body": "{devices} 向けの無料アプリです。デスクトップ版とは機能が異なります。", "link": "{devices} 版 TablePro" }, - "install": { - "title": "インストールと初回起動", - "steps": [ - "ダウンロードした .dmg ファイルを開きます。", - "TablePro をアプリケーションフォルダーにドラッグします。", - "アプリケーションから TablePro を開きます。macOS の確認が表示されたら Open を選びます。", - "接続を追加するか、Help > Open Sample Database から同梱の Chinook SQLite データベースを試せます。" - ], - "signing": "TablePro は Developer ID で署名され、Apple の公証を受けています。開くためにセキュリティ設定を変更する必要はありません。", - "drivers": "一般的なドライバーは同梱されています。その他は初回にインターネットからダウンロードし、アプリとは別に更新されます。", - "databases": "対応データベース", - "guide": { - "label": "インストールガイドを読む(英語)", - "path": "/installation" - } - }, - "updates": { - "title": "アップデート", - "paragraphs": [ - "TablePro は 1 日 1 回アップデートを確認します。新しいバージョンはバックグラウンドでダウンロードされ、次にアプリを終了したときにインストールされます。", - "ほとんどのリリースは約 1 日半かけて段階的に Mac へ配信されます。早く入手するには TablePro > Check for Updates… を選んでください。", - "Settings > General で自動確認や自動インストールをオフにできます。アップデートの確認先はリリースを公開している GitHub です。すべてのアップデートは署名され、インストール前に検証されます。" - ] - }, "otherPlatforms": { - "title": "その他のプラットフォーム", "body": "TablePro は {platforms} に対応していません。リリース日の予定もありません。", "faq": "FAQ のプラットフォームに関する質問" }, "olderVersions": { - "title": "以前のバージョンとリリースノート", - "body": "すべてのリリースのノートとディスクイメージは GitHub Releases にあります。各バージョンの変更内容は変更履歴で確認できます。", + "title": "以前のバージョン", "releases": "GitHub の全リリース", "changelog": "変更履歴(英語)" } diff --git a/resources/data/content/ko/download.json b/resources/data/content/ko/download.json index 500089da..3390e297 100644 --- a/resources/data/content/ko/download.json +++ b/resources/data/content/ko/download.json @@ -8,55 +8,25 @@ "title": "TablePro 다운로드" }, "header": { - "title": "TablePro 다운로드", - "lead": "기기에 맞는 빌드를 선택하세요." + "title": "TablePro 다운로드" }, "mac": { - "otherWays": "다른 설치 방법", "homebrew": { "title": "Homebrew", - "body": "Homebrew는 Mac 칩에 맞는 빌드를 설치합니다. 이후에는 앱이 자동으로 최신 상태를 유지합니다.", "trails": "Homebrew의 배포본은 별도로 업데이트되므로 새 릴리스보다 늦을 수 있습니다. 이전 버전이 설치되면 TablePro > 업데이트 확인…(TablePro > Check for Updates…)을 선택해 최신 버전을 받으세요." }, - "releases": "GitHub Releases의 모든 빌드", + "signing": "TablePro는 Developer ID로 서명되었으며 Apple의 공증을 받았습니다. 열기 위해 보안 설정을 변경할 필요가 없습니다.", "source": "소스에서 빌드 (AGPLv3)" }, "ios": { - "body": "{devices}용 무료 앱으로, 별도의 기능 구성을 제공합니다.", "link": "{devices}용 TablePro" }, - "install": { - "title": "설치 및 첫 실행", - "steps": [ - "다운로드한 .dmg 파일을 여세요.", - "TablePro를 응용 프로그램 폴더로 드래그하세요.", - "응용 프로그램에서 TablePro를 여세요. macOS가 확인을 요청하면 열기 (Open)을 선택하세요.", - "연결을 추가하거나 도움말 > 샘플 데이터베이스 열기 (Help > Open Sample Database)에서 내장된 Chinook SQLite 데이터베이스를 사용해 보세요." - ], - "signing": "TablePro는 Developer ID로 서명되었으며 Apple의 공증을 받았습니다. 열기 위해 보안 설정을 변경할 필요가 없습니다.", - "drivers": "일반적인 드라이버는 앱에 포함됩니다. 그 밖의 드라이버는 처음 사용할 때 인터넷으로 다운로드되며 앱과 별도로 업데이트됩니다.", - "databases": "지원 데이터베이스", - "guide": { - "label": "설치 가이드 읽기 (영어)", - "path": "/installation" - } - }, - "updates": { - "title": "업데이트", - "paragraphs": [ - "TablePro는 하루에 한 번 업데이트를 확인합니다. 새 버전은 백그라운드에서 다운로드되며 다음에 앱을 종료할 때 설치됩니다.", - "대부분의 릴리스는 약 하루 반에 걸쳐 Mac에 단계적으로 배포됩니다. 더 빨리 받으려면 TablePro > 업데이트 확인…(TablePro > Check for Updates…)을 선택하세요.", - "설정 > 일반(Settings > General)에서 자동 확인 또는 자동 설치를 끌 수 있습니다. 업데이트 확인은 릴리스가 게시되는 GitHub로 요청되며, 모든 업데이트는 서명되고 설치 전에 검증됩니다." - ] - }, "otherPlatforms": { - "title": "다른 플랫폼", "body": "TablePro는 {platforms}용으로 제공되지 않으며 출시 일정도 없습니다.", "faq": "FAQ의 플랫폼 관련 질문" }, "olderVersions": { - "title": "이전 버전 및 릴리스 노트", - "body": "모든 릴리스의 노트와 디스크 이미지는 GitHub Releases에 있습니다. 변경 기록에는 각 버전의 변경 사항이 나와 있습니다.", + "title": "이전 버전", "releases": "GitHub의 모든 릴리스", "changelog": "변경 기록 (영어)" } diff --git a/resources/data/content/pt-BR/download.json b/resources/data/content/pt-BR/download.json index d865bf29..41acbc1f 100644 --- a/resources/data/content/pt-BR/download.json +++ b/resources/data/content/pt-BR/download.json @@ -8,55 +8,25 @@ "title": "Baixar TablePro" }, "header": { - "title": "Baixar TablePro", - "lead": "Escolha uma versão para o seu dispositivo." + "title": "Baixar TablePro" }, "mac": { - "otherWays": "Outras formas de instalar", "homebrew": { "title": "Homebrew", - "body": "O Homebrew instala a versão para o chip do seu Mac. Depois disso, o app se mantém atualizado.", "trails": "A cópia do Homebrew é atualizada separadamente e pode ficar atrasada em relação a um novo lançamento. Se ele instalar uma versão anterior, escolha TablePro > Check for Updates… para obter a atual." }, - "releases": "Todas as versões no GitHub Releases", + "signing": "TablePro é assinado com Developer ID e autenticado pela Apple, portanto você não precisa alterar configurações de segurança para abri-lo.", "source": "Compilar a partir do código-fonte (AGPLv3)" }, "ios": { - "body": "Um aplicativo gratuito para {devices}, com seu próprio conjunto de recursos.", "link": "TablePro para {devices}" }, - "install": { - "title": "Instalação e primeiro uso", - "steps": [ - "Abra o arquivo.dmg baixado.", - "Arraste TablePro para a pasta Aplicativos.", - "Abra TablePro em Aplicativos. Escolha Open quando macOS solicitar sua confirmação.", - "Adicione uma conexão ou experimente o banco de dados Chinook SQLite incluído por meio de Help > Open Sample Database." - ], - "signing": "TablePro é assinado com Developer ID e autenticado pela Apple, portanto você não precisa alterar configurações de segurança para abri-lo.", - "drivers": "Drivers comuns estão incluídos. Outros precisam de uma conexão com a Internet para fazer o download no primeiro uso e atualizar separadamente do aplicativo.", - "databases": "Bancos de dados compatíveis", - "guide": { - "label": "Ler o guia de instalação (inglês)", - "path": "/installation" - } - }, - "updates": { - "title": "Atualizações", - "paragraphs": [ - "TablePro verifica atualizações uma vez por dia. Uma nova versão é baixada em segundo plano e instalada na próxima vez que você encerrar o app.", - "A maioria das versões chega aos Macs em etapas ao longo de cerca de um dia e meio. Para obter uma antes, escolha TablePro > Check for Updates…", - "Você pode desativar as verificações ou instalações automáticas em Settings > General. As verificações consultam o GitHub, onde as versões são publicadas, e todas as atualizações são assinadas e verificadas antes da instalação." - ] - }, "otherPlatforms": { - "title": "Outras plataformas", "body": "TablePro não está disponível para {platforms}, e não há data de lançamento.", "faq": "Perguntas sobre plataformas nas perguntas frequentes" }, "olderVersions": { - "title": "Versões anteriores e notas de versão", - "body": "Todas as versões, com suas notas e imagens de disco, estão no GitHub Releases. O histórico de alterações lista o que mudou em cada versão.", + "title": "Versões anteriores", "releases": "Todas as versões no GitHub", "changelog": "Histórico de alterações (inglês)" } diff --git a/resources/data/content/vi/download.json b/resources/data/content/vi/download.json index 9211ea85..69b0f70d 100644 --- a/resources/data/content/vi/download.json +++ b/resources/data/content/vi/download.json @@ -8,55 +8,25 @@ "title": "Tải TablePro" }, "header": { - "title": "Tải TablePro", - "lead": "Chọn bản build cho thiết bị của bạn." + "title": "Tải TablePro" }, "mac": { - "otherWays": "Cách cài đặt khác", "homebrew": { "title": "Homebrew", - "body": "Homebrew tự cài bản phù hợp với chip của máy Mac. Sau đó, ứng dụng tự cập nhật.", "trails": "Bản trên Homebrew được cập nhật riêng nên có thể chưa kịp lên phiên bản mới nhất. Nếu Homebrew cài bản cũ hơn, hãy chọn TablePro > Kiểm tra cập nhật… (TablePro > Check for Updates…) để lên bản mới nhất." }, - "releases": "Mọi bản build trên GitHub Releases", + "signing": "TablePro được ký bằng Developer ID và được Apple notarize, nên bạn không cần thay đổi cài đặt bảo mật nào để mở ứng dụng.", "source": "Build từ mã nguồn (AGPLv3)" }, "ios": { - "body": "Ứng dụng miễn phí cho {devices}, với bộ tính năng riêng.", "link": "TablePro cho {devices}" }, - "install": { - "title": "Cài đặt và mở lần đầu", - "steps": [ - "Mở file .dmg đã tải về.", - "Kéo TablePro vào thư mục Ứng dụng (Applications).", - "Mở TablePro từ Applications. Chọn Open khi macOS hỏi xác nhận.", - "Thêm connection, hoặc thử cơ sở dữ liệu SQLite Chinook có sẵn qua Help > Open Sample Database." - ], - "signing": "TablePro được ký bằng Developer ID và được Apple notarize, nên bạn không cần thay đổi cài đặt bảo mật nào để mở ứng dụng.", - "drivers": "Các driver phổ biến có sẵn. Driver khác cần internet để tải vào lần đầu sử dụng và cập nhật riêng.", - "databases": "Cơ sở dữ liệu được hỗ trợ", - "guide": { - "label": "Đọc hướng dẫn cài đặt (tiếng Anh)", - "path": "/installation" - } - }, - "updates": { - "title": "Cập nhật", - "paragraphs": [ - "TablePro kiểm tra bản cập nhật mỗi ngày một lần. Bản mới được tải về trong nền và được cài vào lần tới bạn thoát ứng dụng.", - "Phần lớn bản phát hành được gửi tới máy Mac theo từng đợt, trong khoảng một ngày rưỡi. Muốn nhận sớm hơn, hãy chọn TablePro > Kiểm tra cập nhật… (TablePro > Check for Updates…).", - "Bạn có thể tắt tự động kiểm tra hoặc tự động cài bản cập nhật trong Cài đặt > Tổng quát (Settings > General). Yêu cầu kiểm tra cập nhật được gửi tới GitHub, nơi đăng các bản phát hành, và mọi bản cập nhật đều được ký và xác minh trước khi cài." - ] - }, "otherPlatforms": { - "title": "Nền tảng khác", "body": "TablePro không có bản cho {platforms}, và không có ngày phát hành.", "faq": "Câu hỏi về nền tảng trong phần Câu hỏi thường gặp" }, "olderVersions": { - "title": "Phiên bản cũ và ghi chú phát hành", - "body": "Mọi bản phát hành, kèm ghi chú và file DMG, đều có trên GitHub Releases. Changelog liệt kê những gì đã thay đổi trong từng phiên bản.", + "title": "Phiên bản cũ", "releases": "Tất cả bản phát hành trên GitHub", "changelog": "Changelog (tiếng Anh)" } diff --git a/resources/data/content/zh-Hans/download.json b/resources/data/content/zh-Hans/download.json index be4a1564..e33508f2 100644 --- a/resources/data/content/zh-Hans/download.json +++ b/resources/data/content/zh-Hans/download.json @@ -8,55 +8,25 @@ "title": "下载 TablePro" }, "header": { - "title": "下载 TablePro", - "lead": "选择适合设备的版本。" + "title": "下载 TablePro" }, "mac": { - "otherWays": "其他安装方式", "homebrew": { "title": "Homebrew", - "body": "Homebrew 会安装适合 Mac 芯片的构建版本。之后应用会自行保持更新。", "trails": "Homebrew 的版本独立更新,可能滞后于新版本。若安装了旧版本,请选择 TablePro > 检查更新…(TablePro > Check for Updates…)获取当前版本。" }, - "releases": "GitHub Releases 上的所有构建版本", + "signing": "TablePro 使用 Developer ID 签名并经 Apple 公证,打开时无需更改任何安全设置。", "source": "从源代码构建(AGPLv3)" }, "ios": { - "body": "适用于 {devices} 的免费 App,功能与桌面版有所不同。", "link": "{devices} 版 TablePro" }, - "install": { - "title": "安装与首次启动", - "steps": [ - "打开下载的 .dmg 文件。", - "将 TablePro 拖到“应用程序”文件夹。", - "从“应用程序”打开 TablePro。macOS 请求确认时,选择 打开 (Open)。", - "添加连接,或通过 帮助 > 打开示例数据库 (Help > Open Sample Database) 试用内置的 Chinook SQLite 示例数据库。" - ], - "signing": "TablePro 使用 Developer ID 签名并经 Apple 公证,打开时无需更改任何安全设置。", - "drivers": "常用驱动已内置。其他驱动在首次使用时需要联网下载,并独立于 App 更新。", - "databases": "受支持的数据库", - "guide": { - "label": "阅读安装指南(英语)", - "path": "/installation" - } - }, - "updates": { - "title": "更新", - "paragraphs": [ - "TablePro 每天检查一次更新。新版本会在后台下载,并在您下次退出应用时安装。", - "大多数版本会在约一天半内分阶段推送至 Mac。若希望更早获取,请选择 TablePro > 检查更新…(TablePro > Check for Updates…)。", - "可在设置 > 通用(Settings > General)中关闭自动检查或自动安装。更新检查会访问发布版本的 GitHub,所有更新均已签名,并在安装前进行验证。" - ] - }, "otherPlatforms": { - "title": "其他平台", "body": "TablePro 不支持 {platforms},也没有发布日期。", "faq": "常见问题中的平台说明" }, "olderVersions": { - "title": "旧版本和发行说明", - "body": "每个版本的说明和磁盘映像均在 GitHub Releases 上。更新日志列出了各版本的改动。", + "title": "旧版本", "releases": "GitHub 上的所有版本", "changelog": "更新日志(英语)" } diff --git a/resources/data/content/zh-Hant/download.json b/resources/data/content/zh-Hant/download.json index b11eb705..a2acf2b0 100644 --- a/resources/data/content/zh-Hant/download.json +++ b/resources/data/content/zh-Hant/download.json @@ -8,55 +8,25 @@ "title": "下載 TablePro" }, "header": { - "title": "下載 TablePro", - "lead": "選擇適合裝置的版本。" + "title": "下載 TablePro" }, "mac": { - "otherWays": "其他安裝方式", "homebrew": { "title": "Homebrew", - "body": "Homebrew 會安裝適合 Mac 晶片的建置版本。之後 App 會自行保持更新。", "trails": "Homebrew 的版本獨立更新,可能落後於新版本。若安裝了舊版本,請選擇 TablePro > 檢查更新…(TablePro > Check for Updates…)取得目前版本。" }, - "releases": "GitHub Releases 上的所有建置版本", + "signing": "TablePro 使用 Developer ID 簽署並經 Apple 公證,開啟時無需更改任何安全性設定。", "source": "從原始碼建置(AGPLv3)" }, "ios": { - "body": "適用於 {devices} 的免費 App,功能與桌面版有所不同。", "link": "{devices} 版 TablePro" }, - "install": { - "title": "安裝與首次啟動", - "steps": [ - "開啟下載的 .dmg 檔案。", - "將 TablePro 拖到「應用程式」資料夾。", - "從「應用程式」開啟 TablePro。macOS 要求確認時,選擇 開啟 (Open)。", - "新增連線,或透過 輔助說明 > 開啟範例資料庫 (Help > Open Sample Database) 試用內建的 Chinook SQLite 範例資料庫。" - ], - "signing": "TablePro 使用 Developer ID 簽署並經 Apple 公證,開啟時無需更改任何安全性設定。", - "drivers": "常用驅動程式已內建。其他驅動程式在首次使用時需要連網下載,並獨立於 App 更新。", - "databases": "支援的資料庫", - "guide": { - "label": "閱讀安裝指南(英文)", - "path": "/installation" - } - }, - "updates": { - "title": "更新", - "paragraphs": [ - "TablePro 每天檢查一次更新。新版本會在背景下載,並在您下次結束 App 時安裝。", - "大多數版本會在約一天半內分階段推送至 Mac。若希望更早取得,請選擇 TablePro > 檢查更新…(TablePro > Check for Updates…)。", - "可在設定 > 一般(Settings > General)中關閉自動檢查或自動安裝。更新檢查會存取發佈版本的 GitHub,所有更新均已簽署,並在安裝前進行驗證。" - ] - }, "otherPlatforms": { - "title": "其他平台", "body": "TablePro 不支援 {platforms},也沒有發佈日期。", "faq": "常見問題中的平台說明" }, "olderVersions": { - "title": "舊版本與版本說明", - "body": "每個版本的說明與磁碟映像檔均在 GitHub Releases 上。更新紀錄列出了各版本的變更。", + "title": "舊版本", "releases": "GitHub 上的所有版本", "changelog": "更新紀錄(英文)" } diff --git a/resources/data/legal/de/privacy.md b/resources/data/legal/de/privacy.md index 2a5e273b..07d0f392 100644 --- a/resources/data/legal/de/privacy.md +++ b/resources/data/legal/de/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics speichert nichts im Browser. Öffentliche Seiten setzen | `_ga`, `_ga_` | Google Analytics nach Einwilligung | Bis 2 Jahre; Ablehnen löscht | | `tablepro:analytics-consent` | Notwendige Wahl, separat Website/Docs | Bis gelöscht | | `tablepro:attribution` | Erstquelle beim Checkout; berechtigtes Interesse | 90 Tage | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Aussehen, gewählte/verworfene Sprachen, geschlossener Angebotshinweis; berechtigtes Interesse | Bis gelöscht; Portaldesign separat | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Aussehen, gewählte/verworfene Sprachen, geschlossener Angebotshinweis, gewählte Linux-Installationsmethode; berechtigtes Interesse | Bis gelöscht; Portaldesign separat | | `mintlify_anonymous_id` | Docs-ID nach dortiger Einwilligung | Bis gelöscht oder abgelehnt | | `crisp-client/`-Cookies | Chatfortsetzung nach Laden auf jeder Seite; berechtigtes Interesse | 6 Monate, bei Rückkehr erneuert | | `__Host-tp_session` | Notwendige Portalanmeldung | 30 Tage angemeldet; beim Anmelden höchstens 10 Minuten; Abmeldung löscht | diff --git a/resources/data/legal/en/privacy.md b/resources/data/legal/en/privacy.md index eeaeb461..71aab4c4 100644 --- a/resources/data/legal/en/privacy.md +++ b/resources/data/legal/en/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics stores nothing in the browser. Public-site pages set no | `_ga`, `_ga_` | Google Analytics, only after analytics consent | Up to 2 years; declining deletes them | | `tablepro:analytics-consent` | Necessary record of your choice; separate on website and docs | Until cleared | | `tablepro:attribution` | First-visit source sent with checkout; legitimate interest | 90 days | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Appearance, chosen/dismissed languages and closed sale notice; legitimate interest | Until cleared; portal theme is separate | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Appearance, chosen/dismissed languages, closed sale notice and chosen Linux install method; legitimate interest | Until cleared; portal theme is separate | | `mintlify_anonymous_id` | Docs visitor ID, only after consent there | Until cleared or consent declined | | `crisp-client/` cookies | Chat continuity on every page after chat loads; legitimate interest | 6 months, renewed on return | | `__Host-tp_session` | Necessary portal sign-in | 30 days signed in; at most 10 minutes during sign-in; sign-out deletes it | diff --git a/resources/data/legal/es/privacy.md b/resources/data/legal/es/privacy.md index 3a7e5ee7..c16fb008 100644 --- a/resources/data/legal/es/privacy.md +++ b/resources/data/legal/es/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics no guarda nada en el navegador. Las páginas públicas | `_ga`, `_ga_` | Google Analytics tras consentir | Hasta 2 años; rechazar las borra | | `tablepro:analytics-consent` | Registro necesario de elección, separado en web/docs | Hasta borrarlo | | `tablepro:attribution` | Origen inicial enviado al pagar; interés legítimo | 90 días | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Aspecto, idiomas elegidos/descartados, aviso de oferta cerrado; interés legítimo | Hasta borrar; tema del portal separado | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Aspecto, idiomas elegidos/descartados, aviso de oferta cerrado, método de instalación en Linux elegido; interés legítimo | Hasta borrar; tema del portal separado | | `mintlify_anonymous_id` | ID de docs tras consentir allí | Hasta borrar o rechazar | | Cookies `crisp-client/` | Continuidad de chat tras cargar en cada página; interés legítimo | 6 meses, renovados al volver | | `__Host-tp_session` | Acceso necesario al portal | 30 días con sesión; hasta 10 minutos durante el acceso; salir la borra | diff --git a/resources/data/legal/fr/privacy.md b/resources/data/legal/fr/privacy.md index 6a419c31..914d4a1f 100644 --- a/resources/data/legal/fr/privacy.md +++ b/resources/data/legal/fr/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics ne stocke rien dans le navigateur. Les pages publiques | `_ga`, `_ga_` | Google Analytics après accord | Jusqu'à 2 ans ; refuser les efface | | `tablepro:analytics-consent` | Choix nécessaire, distinct site/docs | Jusqu'à effacement | | `tablepro:attribution` | Source initiale envoyée à l'achat ; intérêt légitime | 90 jours | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Apparence, langues choisies/écartées, avis fermé ; intérêt légitime | Jusqu'à effacement ; thème du portail distinct | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Apparence, langues choisies/écartées, avis fermé, méthode d'installation Linux choisie ; intérêt légitime | Jusqu'à effacement ; thème du portail distinct | | `mintlify_anonymous_id` | ID docs après accord local | Jusqu'à effacement ou refus | | Cookies `crisp-client/` | Continuité du chat sur chaque page après chargement ; intérêt légitime | 6 mois, renouvelés au retour | | `__Host-tp_session` | Connexion nécessaire au portail | 30 jours connecté ; 10 minutes maximum pendant la connexion ; déconnexion l'efface | diff --git a/resources/data/legal/id/privacy.md b/resources/data/legal/id/privacy.md index ec35b93d..e45edbbf 100644 --- a/resources/data/legal/id/privacy.md +++ b/resources/data/legal/id/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics tidak menyimpan apa pun di browser. Halaman publik tida | `_ga`, `_ga_` | Google Analytics setelah persetujuan | Hingga 2 tahun; menolak menghapus | | `tablepro:analytics-consent` | Pilihan wajib, terpisah situs/docs | Sampai dihapus | | `tablepro:attribution` | Sumber awal bersama checkout; kepentingan sah | 90 hari | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Tampilan, bahasa dipilih/ditutup, promo ditutup; kepentingan sah | Sampai dihapus; tema portal terpisah | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Tampilan, bahasa dipilih/ditutup, promo ditutup, metode instal Linux dipilih; kepentingan sah | Sampai dihapus; tema portal terpisah | | `mintlify_anonymous_id` | ID docs setelah persetujuan di sana | Sampai dihapus/ditolak | | Cookie `crisp-client/` | Kelanjutan chat setelah dimuat tiap halaman; kepentingan sah | 6 bulan, diperbarui saat kembali | | `__Host-tp_session` | Masuk portal yang diperlukan | 30 hari masuk; paling lama 10 menit saat proses; keluar menghapus | diff --git a/resources/data/legal/it/privacy.md b/resources/data/legal/it/privacy.md index fb86b772..1f5f1f38 100644 --- a/resources/data/legal/it/privacy.md +++ b/resources/data/legal/it/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics non salva nulla nel browser. Le pagine pubbliche non im | `_ga`, `_ga_` | Google Analytics dopo consenso | Fino a 2 anni; rifiutare elimina | | `tablepro:analytics-consent` | Scelta necessaria, distinta sito/docs | Fino a cancellazione | | `tablepro:attribution` | Fonte iniziale con checkout; legittimo interesse | 90 giorni | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Aspetto, lingue scelte/scartate, avviso chiuso; legittimo interesse | Fino a cancellazione; tema portale distinto | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Aspetto, lingue scelte/scartate, avviso chiuso, metodo di installazione Linux scelto; legittimo interesse | Fino a cancellazione; tema portale distinto | | `mintlify_anonymous_id` | ID docs dopo consenso locale | Fino a cancellazione o rifiuto | | Cookie `crisp-client/` | Continuità chat dopo caricamento su ogni pagina; legittimo interesse | 6 mesi, rinnovati al ritorno | | `__Host-tp_session` | Accesso necessario al portale | 30 giorni autenticato; massimo 10 minuti durante accesso; uscire elimina | diff --git a/resources/data/legal/ja/privacy.md b/resources/data/legal/ja/privacy.md index ad7b0edb..b1d81222 100644 --- a/resources/data/legal/ja/privacy.md +++ b/resources/data/legal/ja/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics はブラウザーに保存しません。公開ペー | `_ga`, `_ga_` | 同意後の Google Analytics | 最長 2 年、拒否で削除 | | `tablepro:analytics-consent` | 必要な選択記録、サイト/Docs 別 | 消去まで | | `tablepro:attribution` | 決済時の初回流入元、正当な利益 | 90 日 | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | 外観、言語選択/却下、閉じたセール、正当な利益 | 消去まで、ポータルテーマは別 | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | 外観、言語選択/却下、閉じたセール、選んだ Linux インストール方法、正当な利益 | 消去まで、ポータルテーマは別 | | `mintlify_anonymous_id` | Docs 同意後の ID | 消去または拒否まで | | `crisp-client/` Cookie | 各ページ読込後のチャット継続、正当な利益 | 6 か月、再訪で更新 | | `__Host-tp_session` | 必要なポータル認証 | ログイン後 30 日、処理中最大 10 分、ログアウトで削除 | diff --git a/resources/data/legal/ko/privacy.md b/resources/data/legal/ko/privacy.md index bebf4471..752610e5 100644 --- a/resources/data/legal/ko/privacy.md +++ b/resources/data/legal/ko/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics는 브라우저에 저장하지 않습니다. 공개 | `_ga`, `_ga_` | 동의 후 Google Analytics | 최대 2년, 거부하면 삭제 | | `tablepro:analytics-consent` | 필수 선택 기록, 사이트/문서 별도 | 지울 때까지 | | `tablepro:attribution` | 결제 시 첫 출처, 정당한 이익 | 90일 | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | 모양, 언어 선택/닫기, 닫은 할인 알림, 정당한 이익 | 지울 때까지, 포털 테마 별도 | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | 모양, 언어 선택/닫기, 닫은 할인 알림, 선택한 Linux 설치 방법, 정당한 이익 | 지울 때까지, 포털 테마 별도 | | `mintlify_anonymous_id` | 문서 동의 후 방문자 ID | 지우거나 거부할 때까지 | | `crisp-client/` 쿠키 | 페이지 로드 후 채팅 유지, 정당한 이익 | 6개월, 재방문 시 갱신 | | `__Host-tp_session` | 필수 포털 로그인 | 로그인 후 30일, 과정 중 최대 10분, 로그아웃 삭제 | diff --git a/resources/data/legal/pt-BR/privacy.md b/resources/data/legal/pt-BR/privacy.md index 9a9dd522..34d937b7 100644 --- a/resources/data/legal/pt-BR/privacy.md +++ b/resources/data/legal/pt-BR/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics não guarda nada no navegador. Páginas públicas não | `_ga`, `_ga_` | Google Analytics após consentir | Até 2 anos; recusar exclui | | `tablepro:analytics-consent` | Escolha necessária, separada site/docs | Até limpar | | `tablepro:attribution` | Fonte inicial enviada ao checkout; interesse legítimo | 90 dias | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Aparência, idiomas escolhidos/recusados, aviso fechado; interesse legítimo | Até limpar; tema portal separado | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Aparência, idiomas escolhidos/recusados, aviso fechado, método de instalação no Linux escolhido; interesse legítimo | Até limpar; tema portal separado | | `mintlify_anonymous_id` | ID docs após consentir lá | Até limpar ou recusar | | Cookies `crisp-client/` | Continuidade do chat após carregar em toda página; interesse legítimo | 6 meses, renovados ao voltar | | `__Host-tp_session` | Login necessário no portal | 30 dias autenticado; até 10 minutos durante login; sair exclui | diff --git a/resources/data/legal/vi/privacy.md b/resources/data/legal/vi/privacy.md index 36cef1b7..a9eeae1d 100644 --- a/resources/data/legal/vi/privacy.md +++ b/resources/data/legal/vi/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics không lưu gì trong trình duyệt. Trang công khai | `_ga`, `_ga_` | Google Analytics, chỉ sau đồng ý | Tối đa 2 năm; từ chối sẽ xóa | | `tablepro:analytics-consent` | Bản ghi lựa chọn cần thiết; riêng trên website và docs | Đến khi xóa | | `tablepro:attribution` | Nguồn lượt đầu gửi cùng checkout; lợi ích chính đáng | 90 ngày | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | Giao diện, ngôn ngữ chọn/bỏ qua, thông báo sale đã đóng; lợi ích chính đáng | Đến khi xóa; theme cổng riêng | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | Giao diện, ngôn ngữ chọn/bỏ qua, thông báo sale đã đóng, cách cài trên Linux đã chọn; lợi ích chính đáng | Đến khi xóa; theme cổng riêng | | `mintlify_anonymous_id` | ID khách docs, chỉ sau đồng ý tại đó | Đến khi xóa hoặc từ chối | | Cookie `crisp-client/` | Duy trì chat trên mọi trang sau khi tải; lợi ích chính đáng | 6 tháng, gia hạn khi quay lại | | `__Host-tp_session` | Đăng nhập cổng cần thiết | 30 ngày khi đăng nhập; tối đa 10 phút trong quá trình; đăng xuất xóa | diff --git a/resources/data/legal/zh-Hans/privacy.md b/resources/data/legal/zh-Hans/privacy.md index 1889fd96..f54e8352 100644 --- a/resources/data/legal/zh-Hans/privacy.md +++ b/resources/data/legal/zh-Hans/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics 不在浏览器保存内容。公开页面不设第一 | `_ga`, `_ga_` | 同意后的 Google Analytics | 最长 2 年;拒绝即删除 | | `tablepro:analytics-consent` | 必要的选择记录,网站/文档各自保存 | 直到清除 | | `tablepro:attribution` | 随结账发送首次来源;合法利益 | 90 天 | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | 外观、所选/忽略语言、已关闭促销;合法利益 | 直到清除;门户主题独立 | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | 外观、所选/忽略语言、已关闭促销、所选 Linux 安装方式;合法利益 | 直到清除;门户主题独立 | | `mintlify_anonymous_id` | 文档访客 ID,在该处同意后 | 直到清除或拒绝 | | `crisp-client/` Cookie | 每页加载后的聊天延续;合法利益 | 6 个月,返回续期 | | `__Host-tp_session` | 必要门户登录 | 登录后 30 天;登录过程最多 10 分钟;退出删除 | diff --git a/resources/data/legal/zh-Hant/privacy.md b/resources/data/legal/zh-Hant/privacy.md index 91c77967..77bedfee 100644 --- a/resources/data/legal/zh-Hant/privacy.md +++ b/resources/data/legal/zh-Hant/privacy.md @@ -153,7 +153,7 @@ Cloudflare Web Analytics 不在瀏覽器儲存內容。公開頁面不設第一 | `_ga`, `_ga_` | 同意後的 Google Analytics | 最長 2 年;拒絕即刪除 | | `tablepro:analytics-consent` | 必要的選擇記錄,網站/文件各自儲存 | 直到清除 | | `tablepro:attribution` | 隨結賬傳送首次來源;合法利益 | 90 天 | -| `theme`, `tablepro:language`, `tablepro:campaign-closed` | 外觀、所選/忽略語言、已關閉促銷;合法利益 | 直到清除;門戶主題獨立 | +| `theme`, `tablepro:language`, `tablepro:campaign-closed`, `tablepro:linux-install` | 外觀、所選/忽略語言、已關閉促銷、所選 Linux 安裝方式;合法利益 | 直到清除;門戶主題獨立 | | `mintlify_anonymous_id` | 文件訪客 ID,在該處同意後 | 直到清除或拒絕 | | `crisp-client/` Cookie | 每頁載入後的聊天延續;合法利益 | 6 個月,返回續期 | | `__Host-tp_session` | 必要門戶登入 | 登入後 30 天;登入過程最多 10 分鐘;退出刪除 | diff --git a/resources/js/components/download/command-block.tsx b/resources/js/components/download/command-block.tsx index 41e560d3..7f9fdbdb 100644 --- a/resources/js/components/download/command-block.tsx +++ b/resources/js/components/download/command-block.tsx @@ -1,40 +1,38 @@ -import CopyButton from '@/components/ui/copy-button'; +import { Fragment } from 'react'; +import { CodeBlock } from '@/components/ui/code'; import { useI18n } from '@/i18n'; interface CommandBlockProps { - /** The command exactly as it is copied, without a prompt. */ - command: string; - /** The title bar's text, such as "Terminal". */ + // One command per line, exactly as copied: no prompt. + lines: string[]; title: string; - /** Names the command for the copy button and the scroll region: "Homebrew command". */ label: string; + onCopied?: () => void; + className?: string; } -/** - * A shell command with a copy button (design-system §5.3.12, `command` variant). - * - * The `$` prompt is drawn but never selected or copied. The command sits in a - * focusable scroll region, so a narrow screen can scroll it from the keyboard - * instead of clipping it. No version is printed beside it: the Homebrew cask - * can trail a release, so any version here would be wrong some of the time. - */ -export default function CommandBlock({ command, title, label }: CommandBlockProps) { +// One copy button takes every line. A line wraps only at a space (a browser would also break after a hyphen, so every +// token is kept whole), its continuation indented so it never reads as the next command; a token wider than the block +// (a URL) scrolls inside it. +export default function CommandBlock({ lines, title, label, onCopied, className }: CommandBlockProps) { const { m } = useI18n(); return ( -
-
- {title} - -
-
-
-                    
-                    {command}
-                
-
-
+ +
+                
+                    {lines.map((line, index) => (
+                        
+                            {line.split(' ').map((token, position) => (
+                                
+                                    {position > 0 && ' '}
+                                    {token}
+                                
+                            ))}
+                        
+                    ))}
+                
+            
+
); } diff --git a/resources/js/components/download/ios-card.tsx b/resources/js/components/download/ios-card.tsx deleted file mode 100644 index f74631e8..00000000 --- a/resources/js/components/download/ios-card.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import LocaleLink from '@/components/ui/locale-link'; -import { textLinkClasses } from '@/components/ui/text-link'; -import { useI18n } from '@/i18n'; -import { joinList } from '@/i18n/format'; -import { cn } from '@/lib/utils'; -import AppStoreBadge from './app-store-badge'; -import { requirementLine } from './format'; -import type { DownloadContent, IosProp } from './types'; - -interface IosCardProps { - content: DownloadContent; - ios: IosProp; - className?: string; -} - -/** - * The iPhone and iPad card (`#ios`): the App Store badge, the requirement, the - * price facts, and a link to `/ios`, where the app's own scope is explained. - * It is a separate app, not a copy of the Mac app, and the card says so. Like - * the Mac card, it is a cell of the download page's `CellGrid` (§4.7). - * - * Every fact here is from platforms.json. The App Store URL has no country - * segment, so Apple sends each reader to their own storefront. - */ -export default function IosCard({ content, ios, className }: IosCardProps) { - const { m, fmt } = useI18n(); - const devices = joinList(ios.deviceNames, m.common.list); - - return ( -
-

- {devices} -

-

- {fmt(m.platforms.requires, { requirement: requirementLine(ios.requirements, m.platforms) })} -

- - {ios.appStoreUrl !== null && ( -
- -
- )} - - {ios.free && !ios.inAppPurchases &&

{m.platforms.free}

} - -

{fmt(content.ios.body, { devices })}

-

- - {fmt(content.ios.link, { devices })} - - -

-
- ); -} diff --git a/resources/js/components/download/ios-panel.tsx b/resources/js/components/download/ios-panel.tsx new file mode 100644 index 00000000..579d29e0 --- /dev/null +++ b/resources/js/components/download/ios-panel.tsx @@ -0,0 +1,37 @@ +import LocaleLink from '@/components/ui/locale-link'; +import { textLinkClasses } from '@/components/ui/text-link'; +import { useI18n } from '@/i18n'; +import { joinList, keepTogether } from '@/i18n/format'; +import AppStoreBadge from './app-store-badge'; +import { requirementLine } from './format'; +import PanelFacts from './panel-facts'; +import type { DownloadContent, IosProp } from './types'; + +interface IosPanelProps { + content: DownloadContent; + ios: IosProp; +} + +// The App Store URL has no country segment, so Apple sends each reader to their own storefront. +export default function IosPanel({ content, ios }: IosPanelProps) { + const { m, fmt } = useI18n(); + const version = + ios.version !== null + ? ios.publishedAtFormatted !== null + ? fmt(m.download.release.dated, { version: ios.version, date: keepTogether(ios.publishedAtFormatted) }) + : fmt(m.download.release.undated, { version: ios.version }) + : null; + + return ( + } + lines={[[version, ios.free && !ios.inAppPurchases && m.platforms.free], [requirementLine(ios.requirements, m.platforms)]]} + updates={m.download.panel.updates.ios} + > + + {fmt(content.ios.link, { devices: joinList(ios.deviceNames, m.common.shortList) })} + + + + ); +} diff --git a/resources/js/components/download/linux-install.tsx b/resources/js/components/download/linux-install.tsx new file mode 100644 index 00000000..c7825234 --- /dev/null +++ b/resources/js/components/download/linux-install.tsx @@ -0,0 +1,226 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { InlineCode } from '@/components/ui/code'; +import DataTable, { TABLE_CELL, TABLE_HEAD_CELL, TABLE_ROW, TABLE_ROW_HEADER } from '@/components/ui/data-table'; +import Disclosure from '@/components/ui/disclosure'; +import SegmentedControl from '@/components/ui/segmented-control'; +import TextLink, { textLinkClasses } from '@/components/ui/text-link'; +import { Trans, useI18n } from '@/i18n'; +import { joinList } from '@/i18n/format'; +import { trackDownload } from '@/lib/analytics'; +import CommandBlock from './command-block'; +import { megabytes } from './format'; +import { methodLabel } from './linux-text'; +import { CENTRED_DISCLOSURE } from './panel-facts'; +import type { LinuxFile, LinuxMethod, LinuxMethodKind, LinuxProp } from './types'; + +type Tab = LinuxMethodKind | 'files'; + +const LINUX_TAB_STORAGE_KEY = 'tablepro:linux-install'; + +function storedTab(tabs: Tab[]): Tab | null { + try { + const value = window.localStorage.getItem(LINUX_TAB_STORAGE_KEY); + + return tabs.find((tab) => tab === value) ?? null; + } catch { + return null; + } +} + +function storeTab(tab: Tab): void { + try { + window.localStorage.setItem(LINUX_TAB_STORAGE_KEY, tab); + } catch { + // Storage can be off or full; the choice then lasts for this page only. + } +} + +// The server renders the first method; the reader's last choice replaces it after mount. Every panel is in the +// markup, the unchosen ones hidden, so each command is in the page whichever tab shows. +export default function LinuxInstall({ linux }: { linux: LinuxProp }) { + const { m, fmt } = useI18n(); + const strings = m.download.linux; + const tabs: Tab[] = [...linux.methods.map((method) => method.kind), ...(linux.packages.length > 0 ? (['files'] as const) : [])]; + const [tab, setTab] = useState(tabs[0] ?? 'files'); + + useEffect(() => { + const stored = storedTab(tabs); + + if (stored !== null) { + setTab(stored); + } + }, []); + + const label = (each: Tab): string => (each === 'files' ? strings.files : methodLabel(each, linux, strings)); + + return ( +
+ + legend={strings.methods} + name="linux-install" + className="flex justify-center max-sm:[&_label]:px-2.5" + value={tab} + options={tabs.map((each) => ({ value: each, label: label(each) }))} + onChange={(next) => { + setTab(next); + storeTab(next); + }} + /> + +
+ {linux.methods.map((method) => ( + + ))} + {tabs.includes('files') && ( + + )} +
+
+ ); +} + +function MethodPanel({ method, linux, title }: { method: LinuxMethod; linux: LinuxProp; title: string }) { + const { m, fmt } = useI18n(); + const strings = m.download.linux; + const architectures = joinList(linux.architectures, m.download.orList); + const distribution = linux.distributions.find((each) => each.via === method.kind && each.name !== null); + const requirement = + distribution !== undefined && distribution.name !== null + ? fmt(strings.requirement, { name: distribution.name, versions: joinList(distribution.versions, m.download.orList), architectures }) + : fmt(strings.flatpakRequirement, { architectures, version: linux.desktop?.version ?? '' }); + + return ( +
+ trackDownload(`download-page-${method.kind}`, 'linux')} + /> +

{requirement}

+ {method.update !== null && ( +

+ {text} }} /> +

+ )} +
+ ); +} + +function FilesPanel({ linux }: { linux: LinuxProp }) { + const { m, fmt } = useI18n(); + const strings = m.download.linux; + const { release, checksums } = linux; + const example = linux.packages.flatMap((row) => Object.values(row.files)).find((file): file is LinuxFile => file !== null); + + const file = (each: LinuxFile | null): ReactNode => { + if (each === null) { + return ( + + ); + } + + const architecture = each.name.slice(each.name.lastIndexOf('-') + 1, each.name.lastIndexOf('.')); + + return ( + + trackDownload('download-page-files', 'linux')} + className={textLinkClasses('inline', 'justify-self-start font-mono text-[0.8125rem] whitespace-nowrap')} + > + {architecture} + + {each.bytes !== null && {fmt(m.download.file.size, { size: megabytes(each.bytes, m.download.file.number) })}} + + ); + }; + + return ( +
+
+ + + + + {strings.table.package} + + {linux.architectures.map((architecture) => ( + + {architecture} + + ))} + + + + {linux.packages.map((row) => ( + + + {row.distribution !== null && row.series !== null ? `${row.distribution} ${row.series}` : strings.table.bundle} + .{row.format} + + {linux.architectures.map((architecture) => ( + + {file(row.files[architecture] ?? null)} + + ))} + + ))} + + +
+ + {checksums !== null && ( +

+ + checksums.url !== null ? ( + + {text} + + ) : ( + {text} + ), + }} + /> +

+ )} + + {release !== null && ( +

+ + {fmt(strings.release, { version: release.version })} + +

+ )} + + {linux.verify !== null && example !== undefined && checksums !== null && ( + +

{fmt(strings.verify.body, { file: checksums.name })}

+ + +
+ )} +
+ ); +} diff --git a/resources/js/components/download/linux-panel.tsx b/resources/js/components/download/linux-panel.tsx new file mode 100644 index 00000000..424c93d9 --- /dev/null +++ b/resources/js/components/download/linux-panel.tsx @@ -0,0 +1,36 @@ +import TextLink from '@/components/ui/text-link'; +import { Trans, useI18n } from '@/i18n'; +import { keepTogether } from '@/i18n/format'; +import LinuxInstall from './linux-install'; +import type { LinuxProp } from './types'; + +interface LinuxPanelProps { + linux: LinuxProp; + issues: string | null; +} + +// Installing takes a choice of method, so the panel leads with the methods instead of one file. +export default function LinuxPanel({ linux, issues }: LinuxPanelProps) { + const { m, fmt } = useI18n(); + const { release } = linux; + const version = + release !== null + ? release.publishedOn !== null + ? fmt(m.download.release.dated, { version: release.version, date: keepTogether(release.publishedOn) }) + : fmt(m.download.release.undated, { version: release.version }) + : null; + + return ( +
+
+ {version !== null &&

{version}

} + {linux.channel === 'beta' && ( +

+ (issues !== null ? {text} : text) }} /> +

+ )} +
+ +
+ ); +} diff --git a/resources/js/components/download/linux-text.ts b/resources/js/components/download/linux-text.ts new file mode 100644 index 00000000..765b890a --- /dev/null +++ b/resources/js/components/download/linux-text.ts @@ -0,0 +1,22 @@ +// Relative imports only, so `node --test` loads it directly (tests/js/download-linux.test.ts). +import { interpolate } from '../../i18n/core.ts'; +import type { Messages } from '../../i18n/types.ts'; +import type { LinuxDistributionProp, LinuxMethodKind, LinuxProp } from './types.ts'; + +type Strings = Messages['download']['linux']; + +// "Ubuntu 26.04+", from the distribution's oldest version; "any distribution via Flatpak" for the one with no name. +export function distributionFloor(distribution: LinuxDistributionProp, strings: Strings): string { + if (distribution.name === null) { + return strings.anyDistribution; + } + + return interpolate(strings.floor, { name: distribution.name, version: distribution.versions[0] ?? '' }); +} + +// The tab label of an install method: the distribution it serves, or "Flatpak". +export function methodLabel(kind: LinuxMethodKind, linux: LinuxProp, strings: Strings): string { + const distribution = linux.distributions.find((each) => each.via === kind && each.name !== null); + + return distribution !== undefined ? distributionFloor(distribution, strings) : strings.flatpak; +} diff --git a/resources/js/components/download/mac-card.tsx b/resources/js/components/download/mac-card.tsx deleted file mode 100644 index f21cf379..00000000 --- a/resources/js/components/download/mac-card.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { useState, type ReactNode } from 'react'; -import { ChevronRight, Download } from 'lucide-react'; -import Badge from '@/components/ui/badge'; -import Button from '@/components/ui/button'; -import Callout from '@/components/ui/callout'; -import { InlineCode } from '@/components/ui/code'; -import TextLink, { textLinkClasses } from '@/components/ui/text-link'; -import { Trans, useI18n } from '@/i18n'; -import { trackDownload } from '@/lib/analytics'; -import type { DeviceKind } from '@/lib/device'; -import { cn } from '@/lib/utils'; -import CommandBlock from './command-block'; -import { megabytes, requirementLine } from './format'; -import type { DownloadContent, LinksProp, MacProp, ReleaseProp } from './types'; - -/** `…` in the copy marks an app label or menu path. */ -export const UI_TAGS = { ui: (text: string) => {text} }; - -interface MacCardProps { - content: DownloadContent; - release: ReleaseProp; - mac: MacProp; - links: LinksProp; - /** Null until mounted: the server renders the same markup for every device. */ - device: DeviceKind | null; - className?: string; -} - -/** - * The Mac card (`#mac`): the release badge, the universal DMG, its SHA-256 - * when the release data carries it, and the other ways to install - * (design-system §5.3.18 `PlatformCard`, §8.7). It is a cell of the download - * page's `CellGrid`, so it draws no box of its own, and its inner rule runs - * from wall to wall (§4.7). - * - * The DMG link is rendered on the server with its real URL. Nothing navigates - * on its own, and nothing claims a download started: the "Next, install it" - * panel appears only after the reader clicked the button, and offers the same - * file again in case nothing arrived. - */ -export default function MacCard({ content, release, mac, links, device, className }: MacCardProps) { - const { m, fmt } = useI18n(); - const [clicked, setClicked] = useState(false); - - const available = release.source !== 'unavailable'; - const { asset } = release; - - let badge: string | null = null; - - if (available && release.version !== null) { - badge = - release.publishedAtFormatted !== null - ? fmt(m.download.release.badge, { version: release.version, date: release.publishedAtFormatted }) - : fmt(m.download.release.badgeUndated, { version: release.version }); - } - - /** - * The file name in Plex Mono, as identifiers are (design-system §3.1), and - * only the size in tabular figures: tabular figures in Inter spaced the - * name's hyphen out ("TablePro - 0.82.0.dmg"). - */ - function fileLabel(): ReactNode { - if (asset.name === null) { - return null; - } - - const template = asset.bytes !== null ? m.download.file.sized : m.download.file.unsized; - - return ( - {name}').replace('{size}', '{size}')} - tags={{ - file: (text) => {text}, - size: (text) => {text}, - }} - values={{ name: asset.name, size: asset.bytes !== null ? megabytes(asset.bytes, m.download.file.number) : '' }} - /> - ); - } - - const file = fileLabel(); - const status = device === 'ios' || device === 'other' ? m.download.onAnotherDevice : ''; - - return ( -
-
-

- {mac.deviceNames[0] ?? 'Mac'} -

- {badge !== null && {badge}} -
- {mac.requirements && ( -

- {fmt(m.platforms.requires, { requirement: requirementLine(mac.requirements, m.platforms) })} -

- )} - -
-
- - {file !== null &&

{file}

} -
-
- - {/* - * Room reserved on the server, so the sentence for another device - * appears without moving anything below it: two lines below - * 640px, where it wraps in a phone-width card, one above. Not a - * live region: it changes on load, not in answer to anything the - * reader did. - */} -

- {status} -

- - {asset.name !== null && asset.sha256 !== null && ( -
- - -
-

- {text} }} /> -

-

- {asset.name} - {asset.sha256} -

-
-
- )} - - {clicked && asset.name !== null && ( - -

{fmt(m.download.afterClick.body, { file: asset.name })}

-

- ( - trackDownload('download-retry', 'mac')} className={textLinkClasses('inline')}> - {text} - - ), - }} - /> -

-

- - {m.download.afterClick.steps} - - -

-
- )} - - {!available && {m.download.release.unavailable}} - -
-

{content.mac.otherWays}

- - {mac.homebrewCommand !== null && ( -
-

{content.mac.homebrew.title}

-

{content.mac.homebrew.body}

-
- -
- {mac.homebrewTrails ? ( - -

- -

-
- ) : ( -

- -

- )} -
- )} - -
    -
  • - - {content.mac.releases} - -
  • - {links.source !== null && ( -
  • - - {content.mac.source} - -
  • - )} -
-
-
- ); -} diff --git a/resources/js/components/download/mac-panel.tsx b/resources/js/components/download/mac-panel.tsx new file mode 100644 index 00000000..eceb69e4 --- /dev/null +++ b/resources/js/components/download/mac-panel.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { Download } from 'lucide-react'; +import Button from '@/components/ui/button'; +import Callout from '@/components/ui/callout'; +import { CodeBlock, InlineCode } from '@/components/ui/code'; +import Disclosure from '@/components/ui/disclosure'; +import { textLinkClasses } from '@/components/ui/text-link'; +import { Trans, useI18n } from '@/i18n'; +import { keepTogether } from '@/i18n/format'; +import { trackDownload } from '@/lib/analytics'; +import { architecturesText } from '@/lib/data/platforms'; +import { megabytes, requirementLine } from './format'; +import PanelFacts, { CENTRED_DISCLOSURE } from './panel-facts'; +import type { DownloadContent, MacProp, ReleaseProp } from './types'; + +const UI_TAGS = { ui: (text: string) => {text} }; + +interface MacPanelProps { + content: DownloadContent; + release: ReleaseProp; + mac: MacProp; +} + +// The DMG link is in the server render with its real URL, and nothing claims a download started: the install hint +// appears only after the reader clicked, and offers the same file again in case nothing arrived. +export default function MacPanel({ content, release, mac }: MacPanelProps) { + const { m, fmt } = useI18n(); + const [clicked, setClicked] = useState(false); + + const available = release.source !== 'unavailable'; + const { asset } = release; + const version = + available && release.version !== null + ? release.publishedAtFormatted !== null + ? fmt(m.download.release.dated, { version: release.version, date: keepTogether(release.publishedAtFormatted) }) + : fmt(m.download.release.undated, { version: release.version }) + : null; + + return ( +
+ + + {(mac.homebrewCommand !== null || asset.sha256 !== null) && ( +
+ {mac.homebrewCommand !== null && ( +
+

{content.mac.homebrew.title}

+ + {mac.homebrewTrails && ( +

+ +

+ )} +
+ )} + + {asset.name !== null && asset.sha256 !== null && ( + +

{content.mac.signing}

+

+ {text} }} /> +

+

+ {asset.name} + {asset.sha256} +

+
+ )} +
+ )} +
+ ); +} diff --git a/resources/js/components/download/panel-facts.tsx b/resources/js/components/download/panel-facts.tsx new file mode 100644 index 00000000..9b0f7a31 --- /dev/null +++ b/resources/js/components/download/panel-facts.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react'; +import DotList from '@/components/ui/dot-list'; + +// A disclosure under the centred facts: its summary centred, what it opens left-aligned at full width. +export const CENTRED_DISCLOSURE = 'text-center [&>div]:pl-0 [&>div]:text-left'; + +interface PanelFactsProps { + action: ReactNode; + // Each line is one DotList: version and size, then requirement and architecture. + lines: ReactNode[][]; + updates: string; + children?: ReactNode; +} + +// The centred top of a platform panel: the one action, its facts in two short lines, and how it updates. +export default function PanelFacts({ action, lines, updates, children }: PanelFactsProps) { + return ( +
+ {action} +
+ {lines.map((items, index) => ( + + ))} +
+

{updates}

+ {children} +
+ ); +} diff --git a/resources/js/components/download/platform-switch.tsx b/resources/js/components/download/platform-switch.tsx new file mode 100644 index 00000000..f55a9113 --- /dev/null +++ b/resources/js/components/download/platform-switch.tsx @@ -0,0 +1,87 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'; +import SegmentedControl from '@/components/ui/segmented-control'; +import { classifyDevice } from '@/lib/device'; +import { cn } from '@/lib/utils'; +import type { PlatformKey } from './types'; + +export interface PlatformTab { + key: PlatformKey; + // The accessible heading of the panel, and the segment's text. + name: string; + label: ReactNode; + icon: ReactNode; + panel: ReactNode; +} + +interface PlatformSwitchProps { + legend: string; + tabs: PlatformTab[]; +} + +function fromHash(keys: PlatformKey[]): PlatformKey | null { + return keys.find((key) => window.location.hash === `#${key}`) ?? null; +} + +// One panel at a time. The server renders the first platform; before paint, a root `ios` or `linux` class from the head +// script shows that platform instead (app.css), and the layout effect then takes over without a visible swap. A link to +// `#mac`, `#ios` or `#linux` opens that panel, and a choice is kept in the URL so a reload or a shared link keeps it. +// The browser scrolls to a linked panel after the layout effect, so a panel's scroll margin clears the switch above it +// and both land the switch under the header. +export default function PlatformSwitch({ legend, tabs }: PlatformSwitchProps) { + const root = useRef(null); + const keys = tabs.map((tab) => tab.key); + const [chosen, setChosen] = useState(null); + + useLayoutEffect(() => { + const linked = fromHash(keys); + const device = classifyDevice(navigator.userAgent, navigator.maxTouchPoints ?? 0); + + setChosen(linked ?? keys.find((key) => key === device) ?? keys[0]); + + if (linked !== null) { + root.current?.scrollIntoView({ block: 'start' }); + } + }, []); + + useEffect(() => { + const follow = (): void => { + const linked = fromHash(keys); + + if (linked !== null) { + setChosen(linked); + root.current?.scrollIntoView({ block: 'start' }); + } + }; + + window.addEventListener('hashchange', follow); + + return () => window.removeEventListener('hashchange', follow); + }, []); + + const shown = chosen ?? keys[0]; + + return ( +
+ + legend={legend} + name="platform" + size="lg" + value={shown} + options={tabs.map((tab) => ({ value: tab.key, label: tab.label, icon: tab.icon }))} + onChange={(next) => { + setChosen(next); + window.history.replaceState(window.history.state, '', `#${next}`); + }} + /> + + {tabs.map((tab) => ( +
+

+ {tab.name} +

+ {tab.panel} +
+ ))} +
+ ); +} diff --git a/resources/js/components/download/types.ts b/resources/js/components/download/types.ts index 0b45e468..8c9e46d6 100644 --- a/resources/js/components/download/types.ts +++ b/resources/js/components/download/types.ts @@ -1,9 +1,4 @@ -/** - * The props `App\Http\Controllers\DownloadController` sends, and the - * page copy's type. The copy's shape is the English file's: `TranslationsTest` - * holds every other locale to the same keys. - */ -import type { PostSummary } from '@/components/blog/post-list'; +// The props DownloadController sends. The copy's shape is the English file's; TranslationsTest holds the other locales to it. import type { Requirements } from '@/lib/data/platforms'; export type { Requirements }; @@ -11,20 +6,19 @@ export type { Requirements }; export type DownloadContent = typeof import('@data/content/en/download.json'); export interface ReleaseAsset { - /** The universal DMG's file name, or null when the release is unavailable. */ + // Null when no release source answered. name: string | null; - /** The DMG, or GitHub's latest-release page when the release is unavailable. */ + // GitHub's latest-release page when no release source answered. url: string; - /** From the GitHub API only; the appcast fallback has no sizes. */ + // The Sparkle appcast has no sizes or digests, only the GitHub API does. bytes: number | null; - // Lowercase hex, from the GitHub API's asset digest; null from the appcast. sha256: string | null; } export interface ReleaseProp { version: string | null; publishedAt: string | null; - /** Formatted in PHP for the page's locale, so SSR and the browser agree. */ + // Formatted in PHP, so SSR and the browser agree. publishedAtFormatted: string | null; asset: ReleaseAsset; releaseUrl: string; @@ -36,7 +30,7 @@ export interface MacProp { deviceNames: string[]; requirements: Requirements | null; homebrewCommand: string | null; - /** platforms.json says Homebrew still serves an older version than this release. */ + // The Homebrew cask still serves an older version than this release. homebrewTrails: boolean; } @@ -46,26 +40,71 @@ export interface IosProp { appStoreUrl: string | null; free: boolean; inAppPurchases: boolean; + version: string | null; + publishedAtFormatted: string | null; +} + +export type LinuxMethodKind = 'ppa' | 'copr' | 'flatpak'; + +export interface LinuxDistributionProp { + id: string; + name: string | null; + versions: string[]; + via: LinuxMethodKind; +} + +export interface LinuxMethod { + kind: LinuxMethodKind; + commands: string[]; + update: string | null; +} + +export interface LinuxFile { + name: string; + url: string; + bytes: number | null; +} + +export interface LinuxPackageRow { + format: 'deb' | 'rpm' | 'flatpak'; + series: string | null; + distribution: string | null; + // Keyed by the architecture as platforms.json names it; null where the release has no such file. + files: Record; +} + +export interface LinuxProp { + deviceNames: string[]; + channel: 'beta' | 'stable'; + distributions: LinuxDistributionProp[]; + desktop: { name: string; version: string } | null; + architectures: string[]; + methods: LinuxMethod[]; + packages: LinuxPackageRow[]; + checksums: { name: string; url: string | null } | null; + // `{file}` stands for the downloaded file. + verify: string | null; + release: { version: string; publishedOn: string | null; releaseUrl: string; releasesUrl: string } | null; } export interface LinksProp { docs: string | null; changelog: string | null; source: string | null; - /** The AGPL licence, for the structured data. */ license: string | null; + issues: string | null; } +export type PlatformKey = 'mac' | 'ios' | 'linux'; + export interface DownloadPageProps { content: DownloadContent; release: ReleaseProp; mac: MacProp; ios: IosProp | null; - /** Platform ids from platforms.json with nothing to install, such as `linux`. */ + linux: LinuxProp | null; + // Platform ids with nothing to install. unreleased: string[]; links: LinksProp; - /** The featured engines' names, in data order, for the Mac app's structured-data description. */ featuredEngines: string[]; - /** The newest release post, or null when there is none. */ - latestPost: PostSummary | null; } diff --git a/resources/js/components/features/availability-cells.tsx b/resources/js/components/features/availability-cells.tsx new file mode 100644 index 00000000..915592dc --- /dev/null +++ b/resources/js/components/features/availability-cells.tsx @@ -0,0 +1,58 @@ +import type { ReactNode } from 'react'; +import Badge from '@/components/ui/badge'; +import { Availability, type AvailabilityLabels } from '@/components/ui/glyph'; +import { interpolate } from '@/i18n/core'; +import { PAID_FEATURES } from '@/lib/data/paid-features'; +import RichText from './rich-text'; +import type { AvailabilityRow, FeatureLabels } from './types'; + +// A cell either stands in its own column or is folded into the row header as a caption line on a phone, where a +// lone dash would mean nothing, so a folded cell says it in words. + +export function macAvailability(row: AvailabilityRow, labels: FeatureLabels, folded: boolean): ReactNode { + const paid = row.paid !== undefined ? PAID_FEATURES.find((feature) => feature.id === row.paid) : undefined; + + if (row.mac === 'no') { + return folded ? labels.availability.notAvailable : ; + } + + if (paid !== undefined) { + return ( + + {interpolate(labels.availability.plan, { tier: labels.tiers[paid.tier] })} + + ); + } + + return labels.availability.free; +} + +interface PlatformAvailabilityOptions { + labels: FeatureLabels; + values: Record; + folded: boolean; + // What `yes` reads as: "Free" on a feature page, where the column is about cost, "Available" where it is about reach. + yes: string; + // Draws `yes` as a check instead of the word. + check?: boolean; +} + +export function platformAvailability(state: 'yes' | 'no' | 'partial', note: string | undefined, { labels, values, folded, yes, check = false }: PlatformAvailabilityOptions): ReactNode { + if (state === 'partial') { + return ; + } + + if (folded) { + return state === 'yes' ? yes : labels.availability.notAvailable; + } + + if (state === 'yes' && !check) { + return yes; + } + + return ; +} + +function marks(labels: FeatureLabels, included: string): AvailabilityLabels { + return { included, notIncluded: labels.availability.notAvailable }; +} diff --git a/resources/js/components/features/availability-table.tsx b/resources/js/components/features/availability-table.tsx index fd666d06..df72eb00 100644 --- a/resources/js/components/features/availability-table.tsx +++ b/resources/js/components/features/availability-table.tsx @@ -1,12 +1,9 @@ -import type { ReactNode } from 'react'; -import Badge from '@/components/ui/badge'; import DataTable, { TABLE_CELL, TABLE_HEAD_CELL, TABLE_ROW, TABLE_ROW_HEADER } from '@/components/ui/data-table'; -import { Availability } from '@/components/ui/glyph'; import { useI18n } from '@/i18n'; import { joinList } from '@/i18n/format'; -import { PAID_FEATURES } from '@/lib/data/paid-features'; import { iosPlatform, macPlatform } from '@/lib/data/platforms'; import { cn } from '@/lib/utils'; +import { macAvailability, platformAvailability } from './availability-cells'; import RichText from './rich-text'; import type { AvailabilityRow, FeatureLabels } from './types'; @@ -16,59 +13,16 @@ interface AvailabilityTableProps { values: Record; } -/** The two platform columns: shown from 640px, folded into the row header below it. */ +// Shown from 640px; below it their values fold into the row header (design-system §5.3.10, priority columns). const PLATFORM_COLUMN = 'hidden sm:table-cell'; -/** - * "Where it works" (sitemap §E.4 block 4): one row per capability on the - * page, with its plan on the Mac and what exists of it on iPhone and iPad. - * - * The plan comes from `paid-features.json` by id, so a row never retypes a - * tier; anything not listed there is free. The iPhone and iPad app has no - * plans at all, so its column says what exists, not what it costs. Device - * names come from `platforms.json`. - * - * Below 640px the table uses priority columns (design-system §5.3.10): the two - * platform columns are hidden and their values fold into the capability's row - * header as caption lines ("Mac: Free", "iPhone and iPad: Not available"), in - * words rather than a dash, so a phone shows every fact without scrolling and - * a screen reader hears them once, with the row. - */ +// The iPhone and iPad app has no plans, so its column says what exists there, not what it costs. export default function AvailabilityTable({ rows, labels, values }: AvailabilityTableProps) { - const { fmt, m } = useI18n(); - const marks = { included: labels.availability.free, notIncluded: labels.availability.notAvailable }; + const { m } = useI18n(); const mac = joinList(macPlatform().deviceNames, m.common.list); const ios = joinList(iosPlatform().deviceNames, m.common.list); - - function macValue(row: AvailabilityRow, folded: boolean): ReactNode { - const paid = row.paid !== undefined ? PAID_FEATURES.find((feature) => feature.id === row.paid) : undefined; - - if (row.mac === 'no') { - return folded ? labels.availability.notAvailable : ; - } - - if (paid !== undefined) { - return ( - - {fmt(labels.availability.plan, { tier: labels.tiers[paid.tier] })} - - ); - } - - return labels.availability.free; - } - - function iosValue(row: AvailabilityRow, folded: boolean): ReactNode { - if (row.ios === 'yes') { - return labels.availability.free; - } - - if (row.ios === 'no') { - return folded ? labels.availability.notAvailable : ; - } - - return ; - } + const iosValue = (row: AvailabilityRow, folded: boolean) => + platformAvailability(row.ios, row.iosNote, { labels, values, folded, yes: labels.availability.free }); return ( @@ -92,14 +46,14 @@ export default function AvailabilityTable({ rows, labels, values }: Availability - {mac}: {macValue(row, true)} + {mac}: {macAvailability(row, labels, true)} {ios}: {iosValue(row, true)} - {macValue(row, false)} + {macAvailability(row, labels, false)} {iosValue(row, false)} ))} diff --git a/resources/js/components/ui/code.tsx b/resources/js/components/ui/code.tsx index f4449473..56ec9421 100644 --- a/resources/js/components/ui/code.tsx +++ b/resources/js/components/ui/code.tsx @@ -2,12 +2,7 @@ import type { ReactNode } from 'react'; import CopyButton, { type CopyButtonLabels } from '@/components/ui/copy-button'; import { cn } from '@/lib/utils'; -/** - * A literal inside prose: a file name, a flag, a command (design-system - * §5.3.12). Mono at 0.875em of the surrounding text, on `--surface` with the - * chip radius, in the text colour. - */ -/** Up to this many characters, inline code never breaks: a flag split after `--` reads as two tokens. */ +// Up to this many characters, inline code never breaks: a flag split after `--` reads as two tokens. const NOWRAP_LIMIT = 32; export function InlineCode({ children, className }: { children: ReactNode; className?: string }) { @@ -23,47 +18,28 @@ export function InlineCode({ children, className }: { children: ReactNode; class } interface CodeBlockProps { - /** - * Names what the block holds, for its scroll region and its copy button: - * "MCP client configuration", "Homebrew command". A region that scrolls but - * holds nothing focusable is unreachable from the keyboard without it. - */ + // Names the block for its scroll region and its copy button: "Homebrew command". A region that scrolls but holds + // nothing focusable is unreachable from the keyboard without it. label: string; - /** The title bar: a file name, or "Terminal" for a command. Omitted, there is no bar. */ + // "Terminal" or a file name. Without it there is no bar and the copy button sits in the corner. title?: ReactNode; - /** - * The code as text. Rendered in `
`, and what the copy button
-     * copies. Leave it out to pass already-highlighted markup as `children`.
-     */
+    // Rendered in `
` and copied. Leave it out to pass markup as `children`.
     code?: string;
-    /**
-     * A shell command: a `$` prompt is drawn before it in the muted colour,
-     * and never selected or copied.
-     */
+    // Draws a `$` prompt that is never selected or copied.
     command?: boolean;
-    /** The copy button's words, in the page's language. Omitted, there is no copy button. */
+    // Without them there is no copy button.
     copyLabels?: CopyButtonLabels;
-    /** The value to copy when `code` is not given (highlighted `children`). */
+    // What to copy when `code` is not given.
     copyValue?: string;
+    onCopied?: () => void;
     className?: string;
     children?: ReactNode;
 }
 
-/**
- * A block of code (design-system §5.3.12).
- *
- * `--code-background` (the white `--raised`, where the lightest Phiki colour
- * still clears 4.5:1) inside a hairline, at the panel radius. An optional 36px
- * title bar carries the file name and the copy button; without a title the
- * copy button sits in the top-right corner.
- *
- * The body is a focusable scroll region, so a line wider than the column
- * scrolls inside the block, from the keyboard too, instead of widening the
- * page.
- */
-export function CodeBlock({ label, title, code, command = false, copyLabels, copyValue, className, children }: CodeBlockProps) {
+// A line wider than the column scrolls inside the block, from the keyboard too, instead of widening the page.
+export function CodeBlock({ label, title, code, command = false, copyLabels, copyValue, onCopied, className, children }: CodeBlockProps) {
     const value = code ?? copyValue;
-    const copy = copyLabels && value !== undefined ?  : null;
+    const copy = copyLabels && value !== undefined ?  : null;
 
     return (
         
diff --git a/resources/js/components/ui/copy-button.tsx b/resources/js/components/ui/copy-button.tsx index 6f0db792..897ea6a5 100644 --- a/resources/js/components/ui/copy-button.tsx +++ b/resources/js/components/ui/copy-button.tsx @@ -3,17 +3,14 @@ import { Check, Copy } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface CopyButtonLabels { - /** The visible label in the `md` size: "Copy". */ copy: string; - /** Shown for two seconds after a copy, and announced: "Copied". */ copied: string; - /** The accessible name, with `{label}` for what is copied: "Copy {label}". */ + // "Copy {label}": the accessible name of the icon-only size. copyNamed: string; - /** Shown when the clipboard refuses. */ failed: string; } -/** English, for callers that have not passed their language yet. Localized pages pass `labels`. */ +// For callers that have not passed their language yet. Localized pages pass `labels`. const ENGLISH: CopyButtonLabels = { copy: 'Copy', copied: 'Copied', @@ -22,25 +19,18 @@ const ENGLISH: CopyButtonLabels = { }; interface CopyButtonProps { - /** What lands on the clipboard. */ value: string; - /** Names what is copied, for the accessible name: "the Homebrew command". */ + // Names what is copied, for the accessible name: "the Homebrew command". label: string; - /** `sm`: a 32px icon button. `md`: icon and the word "Copy". */ + // `sm`: a 32px icon button. `md`: icon and the word "Copy". size?: 'sm' | 'md'; labels?: CopyButtonLabels; + onCopied?: () => void; className?: string; } -/** - * Copy to clipboard (design-system §5.3.12). - * - * On success the icon becomes a check in `--accent-text` and the label reads - * "Copied" for two seconds, announced through a polite live region instead of - * a toast. On failure a line says so beside the button, and stays until the - * next attempt. - */ -export default function CopyButton({ value, label, size = 'sm', labels = ENGLISH, className }: CopyButtonProps) { +// The success state is announced through a polite live region, not a toast; a failure stays until the next attempt. +export default function CopyButton({ value, label, size = 'sm', labels = ENGLISH, onCopied, className }: CopyButtonProps) { const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle'); const timer = useRef | null>(null); @@ -61,6 +51,7 @@ export default function CopyButton({ value, label, size = 'sm', labels = ENGLISH try { await navigator.clipboard.writeText(value); setState('copied'); + onCopied?.(); timer.current = setTimeout(() => setState('idle'), 2000); } catch { setState('failed'); diff --git a/resources/js/components/ui/dot-list.tsx b/resources/js/components/ui/dot-list.tsx index 39558997..c6f08296 100644 --- a/resources/js/components/ui/dot-list.tsx +++ b/resources/js/components/ui/dot-list.tsx @@ -4,6 +4,8 @@ import { cn } from '@/lib/utils'; interface DotListProps { /** The facts, in order. `null`, `false` and empty strings are left out. */ items: ReactNode[]; + // `center`: one fact per line on a phone, with no dots; from 640px one centred line, the first item without its dot. + align?: 'start' | 'center'; className?: string; } @@ -23,9 +25,24 @@ interface DotListProps { * The 4px of padding inside the clip keeps a focus ring on a leading link * visible. */ -export default function DotList({ items, className }: DotListProps) { +export default function DotList({ items, align = 'start', className }: DotListProps) { const shown = items.filter((item) => item !== null && item !== false && item !== undefined && item !== ''); + if (align === 'center') { + return ( + + {shown.map((item, index) => ( + + {item} + + ))} + + ); + } + return ( diff --git a/resources/js/components/ui/segmented-control.tsx b/resources/js/components/ui/segmented-control.tsx index 10676952..065004d4 100644 --- a/resources/js/components/ui/segmented-control.tsx +++ b/resources/js/components/ui/segmented-control.tsx @@ -14,6 +14,8 @@ interface SegmentedControlProps { onChange: (value: T) => void; name?: string; caption?: ReactNode; + // `lg`: equal segments across the whole width, the icon above the label (the download page's platforms). + size?: 'md' | 'lg'; className?: string; } @@ -25,12 +27,15 @@ interface Thumb { } // Radios, so arrow keys and forms work natively. Once measured, one thumb slides to the checked segment; before that -// (the server render, no JavaScript) the checked segment paints its own ground. -export default function SegmentedControl({ legend, value, options, onChange, name, caption, className }: SegmentedControlProps) { +// (the server render, no JavaScript) the checked segment paints its own ground. The thumb slides only after the reader +// chose: a value the page restores on load lands in place. +export default function SegmentedControl({ legend, value, options, onChange, name, caption, size = 'md', className }: SegmentedControlProps) { const generated = useId(); const group = name ?? generated; const track = useRef(null); + const chosen = useRef(false); const [thumb, setThumb] = useState(null); + const large = size === 'lg'; useLayoutEffect(() => { const element = track.current; @@ -56,19 +61,34 @@ export default function SegmentedControl({ legend, value, opti return (
{legend} -
+
{thumb && ( , - - {m.download.release.notes} - , - ]} - /> - ) : undefined - } - /> - - {/* The two platforms are cells of the page grid, between two joins (design-system §4.7). */} -
+
- - {iosFirst ? ( - <> - {iosCard} - {macCard} - - ) : ( - <> - {macCard} - {iosCard} - - )} - +
+
+ +

{content.header.title}

+
+ + {/* Security links "Download and updates" here; each panel says how its platform updates. */} + +
+ +
+
- {/* What to do after the download: one section each, so the page frame's joins separate them (design-system §4.7). */} -
-
-
    - {content.install.steps.map((step, index) => ( -
  1. - -
  2. - ))} -
-

{content.install.signing}

-

{content.install.drivers}

-
    -
  • - - {content.install.databases} - - -
  • - {links.docs !== null && ( -
  • - - {content.install.guide.label} - -
  • +
    + + 0 && 'lg:grid-cols-3')}> + {notes.length > 0 && ( +
    +

    + {m.download.release.notes} +

    +
      + {notes.map((link) => ( +
    • + + {link.label} + +
    • + ))} +
    +
    )} -
-
-
- -
-
- {content.updates.paragraphs.map((paragraph, index) => ( -

- -

- ))} -
-
- - {unreleasedNames.length > 0 && ( -
-
-

- {fmt(content.otherPlatforms.body, { platforms: joinList(unreleasedNames, m.download.otherPlatforms.joiner) })} -

-

- - {content.otherPlatforms.faq} - - -

-
-
- )} -
-
-

{content.olderVersions.body}

- {latestPost !== null && ( -

- -

- )} -
    -
  • - - {content.olderVersions.releases} - -
  • - {links.changelog !== null && ( -
  • - - {content.olderVersions.changelog} - -
  • +
    +

    + {content.olderVersions.title} +

    +
      +
    • + + {content.olderVersions.releases} + +
    • + {links.source !== null && ( +
    • + + {content.mac.source} + +
    • + )} +
    +
    + + {unreleasedNames.length > 0 && ( +
    +

    + {fmt(m.download.otherPlatforms.question, { platforms: others })} +

    +

    {fmt(content.otherPlatforms.body, { platforms: others })}

    +

    + + {content.otherPlatforms.faq} + + +

    +
    )} -
-
-
+ + +
); } diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index c5b9b159..fe3eccbc 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -33,14 +33,17 @@ {{-- Theme before anything paints: the system's unless the reader chose otherwise. --}} @include('partials.head-theme') - {{-- An iPhone or iPad, by `classifyDevice` in resources/js/lib/device.ts - (tests/js/device.test.ts runs this against it), before first paint: - the App Store badge leads there, and nothing swaps under a finger. --}} + {{-- An iPhone or iPad, or a Linux desktop, by `classifyDevice` in + resources/js/lib/device.ts (tests/js/device.test.ts runs this against + it), before first paint: the App Store badge leads on iOS, the download + page opens on the reader's platform, and nothing swaps under a finger. --}} diff --git a/tests/Feature/Pages/DownloadTest.php b/tests/Feature/Pages/DownloadTest.php index bb841020..9febdf60 100644 --- a/tests/Feature/Pages/DownloadTest.php +++ b/tests/Feature/Pages/DownloadTest.php @@ -6,7 +6,6 @@ use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; use Inertia\Testing\AssertableInertia; -use Spatie\YamlFrontMatter\YamlFrontMatter; use function Pest\Laravel\get; @@ -106,17 +105,170 @@ function downloadFakeRelease(?array $payload = null): void get('/download')->assertInertia(fn(AssertableInertia $page) => $page->where('featuredEngines', $expected)); }); -it('links the newest release post, in English on every page', function (string $path): void { +function downloadLinuxRecord(): array +{ + $linux = collect(json_decode((string) file_get_contents(resource_path('data/platforms.json')), true)['platforms'])->firstWhere('id', 'linux'); + + return [...$linux, 'status' => 'released']; +} + +function downloadReleaseLinux(): array +{ + $linux = downloadLinuxRecord(); + bindReleaseFixturePlatforms(['linux' => $linux]); + + return $linux; +} + +function downloadDocument(string $path = '/download'): Dom\HTMLDocument +{ + requireSsr(); + + return Dom\HTMLDocument::createFromString((string) get($path)->assertOk()->getContent(), LIBXML_NOERROR); +} + +function downloadJsonLdIds(Dom\HTMLDocument $document): array +{ + $ids = []; + + foreach ($document->querySelectorAll('script[type="application/ld+json"]') as $script) { + $data = json_decode((string) $script->textContent, true, 512, JSON_THROW_ON_ERROR); + + foreach ($data['@graph'] ?? [$data] as $node) { + $ids[] = (string) ($node['@id'] ?? ''); + } + } + + return $ids; +} + +it('sends nothing about Linux while it is not released', function (): void { + downloadFakeRelease(); + + get('/download')->assertInertia(fn(AssertableInertia $page) => $page + ->where('linux', null) + ->where('unreleased', fn($platforms): bool => in_array('linux', $platforms->all(), true))); +}); + +it('sends the Linux install methods, packages and release once Linux is released', function (): void { + $linux = downloadReleaseLinux(); + downloadFakeRelease(); + + $methods = collect($linux['destinations']) + ->filter(fn(array $destination): bool => isset($destination['commands'])) + ->map(fn(array $destination): array => ['kind' => $destination['kind'], 'commands' => $destination['commands'], 'update' => $destination['update']]) + ->values() + ->all(); + + get('/download')->assertInertia(fn(AssertableInertia $page) => $page + ->where('linux.methods', $methods) + ->where('linux.release.version', $linux['release']['version']) + ->where('linux.architectures', $linux['architectures']) + ->where('linux.packages', fn($rows): bool => count($rows) === 5 && $rows[0]['files']['x86_64']['name'] === "tablepro-{$linux['release']['version']}-ubuntu-26.04-amd64.deb") + ->where('unreleased', ['windows'])); +}); + +it('takes the Linux packages’ sizes from the newest GitHub release', function (): void { + downloadReleaseLinux(); + Http::fake([ + RELEASES_FAKE_GITHUB_LATEST => Http::response(githubReleasePayload()), + RELEASES_FAKE_APPCAST => Http::response(appcastXml()), + 'api.github.com/repos/TableProApp/TablePro/releases?per_page=*' => Http::response([[ + 'tag_name' => 'linux-v0.1.0-beta.2', + 'draft' => false, + 'prerelease' => true, + 'published_at' => '2026-11-10T09:30:00Z', + 'html_url' => 'https://github.com/TableProApp/TablePro/releases/tag/linux-v0.1.0-beta.2', + 'assets' => [ + githubAsset('tablepro-0.1.0-beta.2-fedora-45-aarch64.rpm', 31_000_000, 'linux-v0.1.0-beta.2'), + githubAsset('SHA256SUMS', 900, 'linux-v0.1.0-beta.2'), + ], + ]]), + ]); + Artisan::call('release:refresh'); + app(App\Services\Releases\LinuxReleaseService::class)->refresh(); + + get('/download')->assertInertia(fn(AssertableInertia $page) => $page + ->where('linux.release.version', '0.1.0-beta.2') + ->where('linux.packages', [[ + 'format' => 'rpm', + 'series' => '45', + 'distribution' => 'Fedora', + 'files' => [ + 'x86_64' => null, + 'arm64' => [ + 'name' => 'tablepro-0.1.0-beta.2-fedora-45-aarch64.rpm', + 'url' => 'https://github.com/TableProApp/TablePro/releases/download/linux-v0.1.0-beta.2/tablepro-0.1.0-beta.2-fedora-45-aarch64.rpm', + 'bytes' => 31_000_000, + ], + ], + ]]) + ->where('linux.checksums.url', 'https://github.com/TableProApp/TablePro/releases/download/linux-v0.1.0-beta.2/SHA256SUMS')); +}); + +it('renders no Linux section, card or structured data while Linux is not released', function (): void { + downloadFakeRelease(); + $document = downloadDocument(); + + expect($document->getElementById('linux'))->toBeNull() + ->and($document->querySelector('#mac'))->not->toBeNull() + ->and($document->querySelector('#ios'))->not->toBeNull() + ->and($document->getElementById('platforms'))->toBeNull() + ->and(collect(downloadJsonLdIds($document))->filter(fn(string $id): bool => str_ends_with($id, '#linux-app')))->toBeEmpty(); +})->group('ssr'); + +it('renders the Linux section with platforms.json’s commands, and the Linux app in the structured data', function (): void { + $linux = downloadReleaseLinux(); + downloadFakeRelease(); + $document = downloadDocument(); + + $expected = collect($linux['destinations'])->filter(fn(array $destination): bool => isset($destination['commands']))->flatMap(fn(array $destination): array => $destination['commands'])->values()->all(); + $rendered = array_map(fn(Dom\Element $line): string => (string) $line->textContent, iterator_to_array($document->querySelectorAll('#linux [role="region"] pre code > span'))); + + expect($document->getElementById('linux'))->not->toBeNull() + ->and(array_slice($rendered, 0, count($expected)))->toBe($expected) + ->and($document->getElementById('platforms'))->toBeNull() + ->and($document->querySelector('input[name="platform"][value="linux"]'))->not->toBeNull() + ->and(collect(downloadJsonLdIds($document))->filter(fn(string $id): bool => str_ends_with($id, '#linux-app')))->toHaveCount(1); +})->group('ssr'); + +it('names each released platform’s version in its own panel, and links its release notes once', function (bool $linuxReleased): void { + if ($linuxReleased) { + downloadReleaseLinux(); + } + + downloadFakeRelease(); + $document = downloadDocument(); + $notes = array_map(fn(Dom\Element $link): string => trim(str_replace('↗', '', (string) $link->textContent)), iterator_to_array($document->querySelectorAll('#release-notes a'))); + + expect((string) $document->getElementById('mac')?->textContent)->toContain('Version 0.77.0') + ->and((string) $document->getElementById('ios')?->textContent)->toContain('Version 1.0,') + ->and(! $linuxReleased || str_contains((string) $document->getElementById('linux')?->textContent, 'Version 0.1.0-beta.1'))->toBeTrue() + ->and($notes)->toBe($linuxReleased ? ['Mac', 'Linux', 'Changelog'] : ['Mac', 'Changelog']) + ->and((string) $document->getElementById('older-versions')?->textContent)->not->toContain('Version'); +})->with(['Mac and iPhone' => false, 'with Linux' => true])->group('ssr'); + +it('renders one platform panel at a time, the Mac first, and keeps the anchors other pages link', function (): void { + downloadReleaseLinux(); downloadFakeRelease(); + $document = downloadDocument(); + $hidden = fn(string $id): bool => in_array('hidden', explode(' ', (string) $document->getElementById($id)?->getAttribute('class')), true); + + expect($document->querySelectorAll('[data-platform-switch] > [data-panel]'))->toHaveCount(3) + ->and($hidden('mac'))->toBeFalse() + ->and($hidden('ios'))->toBeTrue() + ->and($hidden('linux'))->toBeTrue() + ->and($document->querySelector('input[name="platform"][value="mac"]')?->hasAttribute('checked'))->toBeTrue() + ->and($document->querySelectorAll('h1'))->toHaveCount(1); + + foreach (['updates', 'older-versions', 'other-platforms'] as $id) { + expect($document->getElementById($id))->not->toBeNull("#{$id} is gone from /download"); + } +})->group('ssr'); - $newest = collect(glob(resource_path('blog/*.md')) ?: []) - ->map(fn(string $file): array => ['slug' => pathinfo($file, PATHINFO_FILENAME), ...YamlFrontMatter::parseFile($file)->matter()]) - ->filter(fn(array $post): bool => is_string($post['release'] ?? null)) - ->sortByDesc('date') - ->first(); - - get($path)->assertInertia(fn(AssertableInertia $page) => $page - ->where('latestPost.slug', $newest['slug']) - ->where('latestPost.locale', 'en') - ->where('latestPost.url', "/blog/{$newest['slug']}")); -})->with(['/download', '/vi/download']); +it('keeps the page to downloads: no feature table, no engine counts, no screenshot', function (): void { + downloadReleaseLinux(); + downloadFakeRelease(); + + get('/download')->assertInertia(fn(AssertableInertia $page) => $page->missing('parity')->missing('engines')->missing('latestPost')); +}); diff --git a/tests/js/analytics.test.ts b/tests/js/analytics.test.ts index c8ade63d..6a245ed5 100644 --- a/tests/js/analytics.test.ts +++ b/tests/js/analytics.test.ts @@ -33,11 +33,13 @@ test('sends an event once the reader has allowed analytics', () => { trackEvent('checkout_started', { tier: 'starter', cycle: 'yearly' }); trackDownload('header'); trackDownload('ios-page', 'ios'); + trackDownload('download-page-ppa', 'linux'); assert.deepEqual(calls, [ ['event', 'checkout_started', { tier: 'starter', cycle: 'yearly' }], ['event', 'download_click', { location: 'header', platform: 'mac' }], ['event', 'download_click', { location: 'ios-page', platform: 'ios' }], + ['event', 'download_click', { location: 'download-page-ppa', platform: 'linux' }], ]); }); diff --git a/tests/js/device.test.ts b/tests/js/device.test.ts index c2f74446..52343df3 100644 --- a/tests/js/device.test.ts +++ b/tests/js/device.test.ts @@ -15,6 +15,7 @@ const UA = { chromeWindows: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36', android: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36', linux: 'Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0', + chromeOS: 'Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36', }; test('an iPhone is never a Mac, although its user agent says "like Mac OS X"', () => { @@ -57,7 +58,7 @@ test('the App Store card leads only on an iPhone or iPad', () => { }); // The head script cannot import this module, so it repeats the rule. -test('the head script marks exactly the devices classifyDevice calls ios', () => { +test('the head script marks exactly the devices classifyDevice calls ios or linux', () => { const blade = readFileSync(new URL('../../resources/views/app.blade.php', import.meta.url), 'utf8'); const start = blade.indexOf('var ua = navigator.userAgent;'); const end = blade.indexOf('})();', start); @@ -73,7 +74,9 @@ test('the head script marks exactly the devices classifyDevice calls ios', () => run({ userAgent, maxTouchPoints }, { documentElement: { classList: { add: (cls: string) => added.push(cls) } } }); - assert.deepEqual(added, appStoreFirst(classifyDevice(userAgent, maxTouchPoints)) ? ['ios'] : [], `${name}, ${maxTouchPoints} touch points`); + const kind = classifyDevice(userAgent, maxTouchPoints); + + assert.deepEqual(added, kind === 'ios' || kind === 'linux' ? [kind] : [], `${name}, ${maxTouchPoints} touch points`); } } }); diff --git a/tests/js/download-linux.test.ts b/tests/js/download-linux.test.ts new file mode 100644 index 00000000..833d2429 --- /dev/null +++ b/tests/js/download-linux.test.ts @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import en from '../../resources/js/i18n/messages/en/index.ts'; +import { distributionFloor, methodLabel } from '../../resources/js/components/download/linux-text.ts'; +import type { LinuxProp } from '../../resources/js/components/download/types.ts'; +import platforms from '../../resources/data/platforms.json' with { type: 'json' }; + +const record = platforms.platforms.find((platform) => platform.id === 'linux') as unknown as LinuxProp & { destinations: { kind: string }[] }; +const linux = { ...record, methods: [], packages: [], checksums: null, verify: null, release: null } as LinuxProp; +const strings = en.download.linux; + +test('a distribution reads as its oldest version and up, and the unnamed one as Flatpak', () => { + const [ubuntu, fedora, other] = linux.distributions; + + assert.equal(distributionFloor(ubuntu, strings), `Ubuntu ${ubuntu.versions[0]}+`); + assert.equal(distributionFloor(fedora, strings), `Fedora ${fedora.versions[0]}+`); + assert.equal(distributionFloor(other, strings), strings.anyDistribution); +}); + +test('each install method is labelled by the distribution it serves', () => { + assert.equal(methodLabel('ppa', linux, strings), `Ubuntu ${linux.distributions[0].versions[0]}+`); + assert.equal(methodLabel('copr', linux, strings), `Fedora ${linux.distributions[1].versions[0]}+`); + assert.equal(methodLabel('flatpak', linux, strings), strings.flatpak); +});