Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions app/Support/Banner.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,10 @@ public static function hiddenOnIosDevices(Request $request): bool
/**
* The shared `banner` prop, or null where the banner is off or not on this
* page. Null rather than a flag, so the component renders nothing at all.
* Only the link: the words are the `banner` catalog's, and the banner
* cannot be closed, so there is no version to close it at.
*
* @return array{href: string, version: string}|null
* @return array{href: string}|null
*/
public static function forRequest(Request $request): ?array
{
Expand All @@ -54,7 +56,6 @@ public static function forRequest(Request $request): ?array

return [
'href' => (string) config('banner.href'),
'version' => (string) config('banner.version'),
];
}

Expand Down
25 changes: 6 additions & 19 deletions config/banner.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@
| on every public page except Pricing, which is where it leads
| (App\Support\Banner). It scrolls away with the page rather than sticking.
|
| Config holds the switch, the link and the dismissal version, and nothing
| else. The words live in the `banner` UI catalog, once per language
| Config holds the switch and the link, and nothing else. The words live in
| the `banner` UI catalog, once per language
| (resources/js/i18n/messages/{locale}/banner.ts), so the banner reads in
| the page's language and its length limits are checked in each
| (TopBannerTest).
|
| It states a fact and never pleads: no "we need your help", no countdown,
| no orange fill. A reader who closes it does not see it for 30 days, and a
| reader who says they have a license, or buys one, not for a year
| (resources/js/lib/banner.ts).
| no orange fill, only a tint of the brand hue (app.css). It cannot be
| closed: there is no close button, no stored record and no version, so
| every reader sees it on every page it belongs on.
|
| `BANNER_ENABLED=false` removes it from the DOM entirely: no hidden
| element, no reserved height, no dismissal script.
| element, no reserved height.
|
*/

Expand All @@ -37,17 +37,4 @@
*/
'href' => env('BANNER_HREF', '/pricing'),

/**
* Bump to show the banner again to readers who closed an earlier one.
*
* A closed banner is stored as `tablepro:banner-dismissed` in
* `localStorage`: this version and the time it stays hidden until. There
* is no session on this domain and the server sets no cookie, so the
* browser is the only place a dismissal can live. Change it whenever the
* catalog wording changes: a reader who closed the old message has not
* read the new one. A license holder's year-long dismissal covers every
* version.
*/
'version' => env('BANNER_VERSION', '3'),

];
11 changes: 5 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,12 +370,11 @@ nothing else. An event before the answer is dropped, not queued.
Events keep the names the Plausible goals had: `download_click` (`location`,
`platform`), `checkout_started` (`tier`, `cycle`) and
`newsletter_signup_clicked` (`source`). The license banner adds
`license_banner_view` and `license_banner_click` (`version`) and
`license_banner_dismiss` (`version`, `reason`: `closed` or `licensed`), so a
banner's clicks can be compared with its views, and with `checkout_started`,
per message. GA4 stores those parameters from the
first hit but only shows them in reports once each is registered as an
event-scoped custom dimension under Admin → Custom definitions. Page changes
`license_banner_view` and `license_banner_click`, without parameters, so a
banner's clicks can be compared with its views and with `checkout_started`.
It cannot be closed, so there is no dismiss event. GA4 stores the parameters
from the first hit but only shows them in reports once each is registered as
an event-scoped custom dimension under Admin → Custom definitions. Page changes
between Inertia visits are counted by enhanced measurement's "page changes
based on browser history events", which must stay on in the web stream.

Expand Down
6 changes: 3 additions & 3 deletions docs/rebuild/design/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ does not call `/api/newsletter/stats` at all (§1.14). A Pest guard scans `resou
| `localization` | closure → `{ switcher: [{locale, native, hreflang, href, current, fallback}] }` | `href` is the alternate for that locale when one exists. Otherwise it is that locale's section index (for example `/vi/blog`) with `fallback: true`, and the switcher shows a short note ("Trang này chưa có bản tiếng Việt") |
| `seo` | closure → `{ robots: 'index, follow' \| 'noindex, follow', canonical: string \| null, alternates: [{hreflang, href}], xDefault, ogLocale, ogLocaleAlternates[], ogImage: {url, width, height, type} \| null }` | Built by `App\Support\Seo\SeoContext::forRequest()` from the `PageRegistry` (§1.6). For a page that renders but is not indexed in this locale (`/vi/blog`), and for an unmatched route or error page, the value is `robots: 'noindex, follow'`, `canonical: null` and no alternates |
| `lcpAsset` | `{light, dark} \| null` (page prop, not shared) | Set by a controller only for a **supplied** asset with `priority: true`, through `AssetManifest::lcpDescriptor()` (§1.9). `app.blade.php` reads it to emit one preload for the active theme |
| `banner` | `{href, version} \| null` | The **copy** moves to the `banner` catalog namespace. Config keeps only `enabled`, `href` and `version` (`config/banner.php` is trimmed) |
| `banner` | `{href} \| null` | The **copy** moves to the `banner` catalog namespace. Config keeps only `enabled` and `href` (`config/banner.php` is trimmed); the banner cannot be closed, so there is no version (2026-10-22) |
| `crispWebsiteId` | `config('services.crisp.website_id')` | Public value, used only by the click-to-load helper (§1.12) |
| `assetPlaceholders` | `! App::isProduction()` | False in production, where an asset slot with no image renders nothing (§1.9) |

Expand Down Expand Up @@ -1080,7 +1080,7 @@ Windows or Linux buttons appear.
| `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` |
| Storage keys `tablepro:analytics-consent`, `theme`, `tablepro:attribution`, `tablepro:banner-dismissed` | Unchanged. `theme` now has writers (§1.10) |
| 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) |
| Assets the platform pulls from this app: `/logo.png`, `/images/logo.png`, `/site.webmanifest`, `/privacy#cookies` | **Do not move.** `#cookies` stays an explicit heading id in both locales' privacy markdown |
Expand Down Expand Up @@ -1167,7 +1167,7 @@ cases call live `api.github.com` today.
| `Landing/LicenseFeaturesTest.php` | **Rewrite** | Run it against `paid-features.json` (10, 8/2, order, names) and the `en`/`vi` detail files; each feature named once in the plan table (SSR). Drop the reading-budget pin |
| `Landing/ProductHuntBadgeTest.php` | **Delete** | The hotlink is removed. If the design keeps a local badge, assert it is a local file |
| `Landing/PurchaseAttributionTest.php` | **Keep, update paths** | Add: `locale` is present in the checkout body, and attribution is still attached before `fetch('/checkout')` |
| `Landing/TopBannerTest.php` | **Keep semantics, rewrite** | Keep the pre-paint dismissal script, "disabled leaves no trace" and the href. The length limits move to `banner` catalog values in both locales |
| `Landing/TopBannerTest.php` | **Keep semantics, rewrite** | Keep "disabled leaves no trace" and the href; the banner cannot be closed, so there is no dismissal script to keep (2026-10-22). The length limits move to `banner` catalog values in both locales |
| `Seo/SeoSmokeTest.php` | **Rewrite** | Iterate the registry for both locales: the component name and a 200 |
| `Seo/StaleClaimsTest.php` | **Delete → `Content/BannedClaimsTest`** | It keeps the slugs-equal-routes rule and the no-ratings rule, and adds the missing guards (MCP tool count, AI provider count, macOS floor) |
| `tests/js/consent.test.ts`, `tests/js/attribution.test.ts` | **Keep** | Paths unchanged (the shared `consent.ts` keeps its location) |
Expand Down
8 changes: 4 additions & 4 deletions docs/rebuild/design/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@ Repo key:
| `ThemeControl` | Shared | **New** | — (none rendered on the old site) | `components/shared/theme-control.tsx`: `variant="menu"` (header `menuitemradio` menu), `variant="segmented"` (mobile menu) and `variant="icons"` (footer bar); labels as props |
| `SiteHeader` | P | **Replace** | `landing/header.tsx` (both) | Nav per sitemap §B.1 (Features menu, Databases, Pricing, Docs, Blog) plus language, theme, Account and Download. **No Download dropdown**: Download goes to `/download`. The license repo's copy is deleted; it gets its own slim headers (§5.4) |
| `MobileNav` | P | **Refactor** | `landing/mobile-nav.tsx` (both) | Full-height sheet dialog. Toggle gets `aria-expanded`, which the old one lacked. The license repo's copy is deleted |
| `SupportBanner` | P | **Refactor** | `landing/support-banner.tsx` | Neutral `--surface` band, not an orange fill. Key `tablepro:banner-dismissed` unchanged |
| `SupportBanner` | P | **Refactor** | `landing/support-banner.tsx` | A tint of the brand hue over the page colour, not an orange fill. It cannot be closed: no dismiss, no stored key (2026-10-22) |
| `SiteFooter` | P | **Replace** | `landing/footer.tsx` (both) | Groups and links per sitemap §B.3 with positioning §10.2's labels, newsletter form and cookie settings, as cells, closed by the shared FooterBar (language menu, theme). No giant wordmark. The license repo's copy is replaced by its own slim footer (§5.4), which ends on the same FooterBar |
| `ConsentBar` | Shared | **Refactor** | `landing/consent-bar.tsx` (both) | Moves to `components/shared/consent-bar.tsx` with `lib/consent.ts`. Compact. Equal-weight Allow and Decline. Storage key and head order unchanged |
| `Field`, `Input`, `Select`, `Checkbox`, `Stepper` | Shared\* | **Keep/refactor** | A `ui/field.tsx`, `select.tsx`, `stepper.tsx` | Shared form styling. Native controls with `accent-color` |
Expand Down Expand Up @@ -857,9 +857,9 @@ One shared file, `components/shared/theme-control.tsx` (architecture §1.10 and
**SupportBanner:**

- The license banner (decided 2026-10-05): on by default on every public page except Pricing, above the sticky header, scrolling away with the page. Off on `/ios` for a reader on an iPhone or iPad, whose app has no license.
- 40px `--surface` band with a 1px `--rule` bottom border; text `small` `--foreground`; link `standalone`. From 1024px the sentence and "Get a license"; below, the short question ("Use TablePro daily?") and the same link, within 248px so it fits a 320px screen; from 1280px also "Have a license? Hide this".
- Dismiss is a 32px `quiet` icon button labelled "Dismiss" / "Ẩn thông báo", in a 44px-wide target. Closing it hides the bar for 30 days at that version; "Have a license?" and a purchase hide it for a year at every version (`lib/banner.ts`).
- Mechanics: `--banner-h`, `has-banner` stamped server-side on the pages that show it, and the dismissal record settled before first paint.
- 40px band on a wash of the brand hue, left to right: `linear-gradient(90deg, color-mix(in oklab, --accent 14%, --background), color-mix(in oklab, --accent 6%, --background))` (oklab, since the neutrals write hue 0 and a polar mix would swing towards red), under a 1px rule of `--accent` 30% in `--rule` (app.css). A tint, not a fill: text `small` `--foreground` (17.5:1 light, 14.5:1 dark on the strongest stop); link `standalone` `--accent-text` (5.1:1 light, 8.2:1 dark). Forced colours drop the gradient. From 1024px the sentence and "See plans"; below, the short question ("Optional paid plans") and the same link, within 248px so it fits a 320px screen.
- It cannot be closed (decided 2026-10-22): no dismiss button, no "Have a license?" link, no stored record, no version. Every reader sees it on every page it belongs on.
- Mechanics: `--banner-h`, `has-banner` stamped server-side on the pages that show it; the one browser-side removal is the `/ios` device rule, before first paint.

**SiteFooter:**

Expand Down
2 changes: 1 addition & 1 deletion docs/rebuild/design/sitemap.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ Two lists drive this. **`alternates`** (for hreflang) holds real translations on

**Theme across both apps.** Both use the same localStorage `theme` key on the same origin. Both head scripts default to `system` when nothing is stored, guard storage access with try/catch, and set the class before first paint. Screenshot variants follow the site's theme class, not `prefers-color-scheme`.

**License banner.** `config/banner.php` is on by default (owner decision, 2026-10-05), on every public page except /pricing in every language, and links to /pricing or to a release post. On /ios a reader on an iPhone or iPad does not get it: the license is for the Mac app. It asks a regular user to buy a license and says what a license adds and pays for; it never pleads. Its dismiss key `tablepro:banner-dismissed` holds the closed version and an end date: 30 days after closing, a year for a license holder or a buyer. The copy "The whole app is free" stays deleted.
**License banner.** `config/banner.php` is on by default (owner decision, 2026-10-05), on every public page except /pricing in every language, and links to /pricing or to a release post. On /ios a reader on an iPhone or iPad does not get it: the license is for the Mac app. It asks a regular user to buy a license and says what a license adds and pays for; it never pleads. It cannot be closed (decided 2026-10-22): no dismiss button and no stored record, so the `tablepro:banner-dismissed` key is retired. The copy "The whole app is free" stays deleted.

---

Expand Down
39 changes: 33 additions & 6 deletions resources/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,12 @@ html {
* (landing-layout.tsx), so an anchor only has to clear the header: 64px plus
* 16px of air.
*
* Zero by default, so a disabled or dismissed banner leaves nothing behind.
* `html.has-banner` is stamped server-side in app.blade.php on every page that
* shows it, and removed there before first paint while the reader's dismissal
* lasts (lib/banner.ts), so a dismissed banner never flashes and a disabled one
* reserves nothing. Visibility lives here rather than in React state for the
* same reason: state would resolve one paint too late.
* Zero by default, so a disabled banner leaves nothing behind. `html.has-banner`
* is stamped server-side in app.blade.php on every page that shows it. The
* banner cannot be closed, so the class stays for the whole visit; the one
* place it comes off in the browser is the iPhone and iPad page, for a reader
* on one of those devices, before first paint. Visibility lives here rather
* than in React state so that the first frame is already right.
*
* The slot holds one bar at a time (TopBanner). The language bar, which offers
* this page in the reader's language, takes the same height and hides the
Expand Down Expand Up @@ -181,6 +181,33 @@ html.has-language-bar .support-banner {
display: none;
}

/*
* The license banner's ground: a wash of the brand hue over the page colour,
* strongest at the left and thinning to the right, under a rule in the same
* hue. A tint, not a fill: the text stays `--foreground` and the link
* `--accent-text`, which measure 17.5:1 and 5.1:1 on the strongest stop in
* light and 14.5:1 and 8.2:1 in dark, within the design system's text pairs
* (its lowest is 5.15:1). Mixed in oklab, not oklch: the neutral tokens
* write their hue as 0, and a polar mix would swing the tint from the brand's
* 55 towards red. `bg-surface` on the element is the ground where
* `color-mix()` is unknown, and in forced colours the band is Canvas like
* every other band.
*/
.support-banner {
background-image: linear-gradient(
90deg,
color-mix(in oklab, var(--accent) 14%, var(--background)),
color-mix(in oklab, var(--accent) 6%, var(--background))
);
border-color: color-mix(in oklab, var(--accent) 30%, var(--rule));
}

@media (forced-colors: active) {
.support-banner {
background-image: none;
}
}

html {
scroll-padding-top: 5rem;
}
Expand Down
4 changes: 2 additions & 2 deletions resources/data/legal/de/privacy.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Datenschutzerklärung
description: Welche Daten die TablePro-Apps, die Website und das Kontoportal erfassen, wohin sie gehen, wie lange sie bleiben und wie du sie änderst oder löschst.
updatedAt: "2026-10-10"
updatedAt: "2026-10-22"
---

Diese Erklärung gilt für TablePro für Mac, TablePro für iPhone und iPad, die Website tablepro.app, die Dokumentation unter docs.tablepro.app und das Kontoportal tablepro.app/account. Sie beschreibt, was diese heute tatsächlich senden und speichern. Beide Apps sind quelloffen unter der AGPLv3. Den Code, der die unten genannten Daten sendet, kannst du im [TablePro-Repository]({github}) lesen.
Expand Down Expand Up @@ -151,7 +151,7 @@ Das Lesen der öffentlichen Website und ihre Anfragen zu Newsletter, Checkout un
- **`_ga` und `_ga_<ID>`** (Google-Analytics-Cookies, bis zu 2 Jahre, nur mit deiner Erlaubnis): eine zufällige Browserkennung und der Status deines aktuellen Besuchs. Ablehnen oder späteres Ändern deiner Antwort löscht sie. Rechtsgrundlage: Einwilligung.
- **`tablepro:analytics-consent`** (lokaler Speicher, bis du ihn löschst): deine Antwort auf die Analysefrage, damit sie nicht auf jeder Seite erscheint. Website und Kontoportal teilen diesen Eintrag. Rechtsgrundlage: unbedingt erforderlich, um deine Wahl zu beachten.
- **`tablepro:attribution`** (lokaler Speicher, 90 Tage): der unter [Website](#website) beschriebene Erstbesuchsdatensatz. Er enthält keine persönliche Kennung und wird nur mit einer Checkout-Anfrage gesendet, bei der unser Server ihn verwirft. Rechtsgrundlage: berechtigtes Interesse.
- **`theme`** und **`tablepro:banner-dismissed`** (lokaler Speicher, bis du ihn löschst): deine Wahl zwischen heller, dunkler oder Systemdarstellung sowie ausgeblendetes Banner und Ausblendungsdauer: 30 Tage oder ein Jahr, wenn du angibst, eine Lizenz zu haben, oder eine kaufst. Rechtsgrundlage: berechtigtes Interesse.
- **`theme`** (lokaler Speicher, bis du ihn löschst): deine Wahl zwischen heller, dunkler oder Systemdarstellung. Rechtsgrundlage: berechtigtes Interesse.
- **`tablepro:language`** (lokaler Speicher, bis du ihn löschst): die Sprache, die du im Sprachmenü oder in der Sprachleiste gewählt hast, und jede Sprache, deren Vorschlag du geschlossen hast, damit sie nicht erneut angeboten wird. Rechtsgrundlage: berechtigtes Interesse.
- **`mintlify_anonymous_id`** (lokaler Speicher auf docs.tablepro.app, von Mintlify gesetzt, nur wenn du dort Google Analytics zulässt): die unter [Website](#website) beschriebene Besucher-ID. Beim Ablehnen wird sie gelöscht. Die Dokumentation speichert ihre eigene Antwort `tablepro:analytics-consent`. Rechtsgrundlage: Einwilligung.
- **Cookies beginnend mit `crisp-client/`** (Crisp, etwa `crisp-client/session/…`; 6 Monate, bei Rückkehr erneuert; auf jeder Seite nach dem Laden des Chats gesetzt): halten Chat und Gespräch über Seiten und Besuche hinweg verfügbar. Rechtsgrundlage: berechtigtes Interesse, um auf jeder Seite Support anzubieten.
Expand Down
Loading