CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Event Schedule is an open-source platform for sharing events, selling tickets, and bringing communities together. It supports both hosted (SaaS at eventschedule.com) and selfhosted deployments.
## Important Rules
- **`php artisan test` is safe to run locally, including concurrently** - `tests/bootstrap.php` gives each session its own `eventschedule_test_<token>` schema (derived from `CLAUDE_CODE_SESSION_ID`, or `TEST_DB_TOKEN` to pick one by hand), so parallel runs cannot drop each other's tables. With neither variable set it falls back to the shared `eventschedule_test`, which is what CI uses. It never touches the `eventschedule` dev database: `phpunit.xml` forces `DB_DATABASE`, and `tests/TestCase.php` refuses to run against anything but a `*_test` schema. Two runs that do land on the same schema queue on a lock rather than corrupt it. See `tests/TestDatabase.php`.
- **`php artisan dusk` needs `php artisan serve` running first** - Dusk swaps `.env` for `.env.dusk.local`, which points at its own `eventschedule_test_dusk` schema and `http://127.0.0.1:8000`. It wipes that schema, never the `eventschedule` dev database. Serve on port 8000 before running it, or every journey fails to connect.
- **A test must never pin `config(['app.url' => ...])` on its own - use `$this->pinAppUrl()`** - Laravel's `SetRequestForConsole` synthesizes the app's request from `APP_URL` at bootstrap, and `MakesHttpRequests::prepareUrlForRequest()` is `trim(url($uri), '/')`, so the host a relative `$this->get('/path')` reaches is fixed before the test body runs. Moving `app.url` alone moves `_base_domain()` and leaves that host behind, and with `IS_HOSTED=true` `ResolveCustomDomain` reads the mismatch as an unknown custom domain and `abort(404)`s before the session middleware runs - so the page 404s and its `<meta name="csrf-token">` comes back EMPTY, neither of which looks like a URL problem. Locally the pin is usually a no-op because it matches `.env`, which is why this only ever showed up on CI, where `.env.example` ships `APP_URL` empty. `phpunit.xml` now forces `APP_URL` (mirrored into `$_SERVER` by `tests/bootstrap.php`) so the two agree by default, and `MarketingEdgeCacheTest::test_the_test_client_reaches_the_base_domain` fails the build if they drift. Driving absolute URLs (`$this->get('https://host/path')`) is the other correct option, and is what `SitemapTest` and `HostedLoginRedirectTest` do.
- **A test that calls `refreshApplication()` loses the Vite stub** - `withoutVite()` binds its stub with `$this->app->instance()`, so it belongs to ONE container; `refreshApplication()` builds a new one and `FoundationServiceProvider` puts the real `Illuminate\Foundation\Vite` back. `@vite` compiles to `app('Illuminate\Foundation\Vite')(...)`, which reads `public/build/manifest.json` - gitignored, and never built by the `feature-tests` job - so the next page that renders a layout throws `ViteManifestNotFoundException` and 500s on CI while working locally, where the manifest exists. `tests/TestCase.php` re-applies the stub inside its own `refreshApplication()` override; `tests/Feature/TestEnvironmentTest.php` fails the build if that override is removed.
- **`putenv()` cannot change what `config()` sees - use `$_SERVER`** - Laravel's `Env` repository reads `$_SERVER`, then `$_ENV`, then `getenv()`, and phpunit.xml's `<env>` entries land in `$_ENV`. So `putenv('APP_TESTING=false')` before a `refreshApplication()` is silently outranked, `config('app.is_testing')` stays `true`, and `routes/web.php` registers the wrong half of its `hosted && ! is_testing` split - which is how `RouteLoadTest::test_hosted_gp_routes_load` spent its life asserting against the domain-less marketing homepage instead of the guest portal. Its `forceEnv()` helper writes all three layers and restores them in `tearDown()`; the same reasoning is why `tests/bootstrap.php` mirrors phpunit.xml's forced vars into `$_SERVER`.
- **Geocode only through `GeocodingService::lookup()`, and never let a test reach Google** - Google bills the Geocoding API per request, a `ZERO_RESULTS` answer included. `Role`'s `saving` hook geocodes a schedule whose composed address is not the one in `geo_address`, and `saving` fires before Eloquent's dirty check, so a no-op `save()` counts. `App\Services\GeocodingService` caches by address (30 days for an answer or a definitive miss, five minutes for a failure in transit), and the hook records a miss in `geo_address` too, so a schedule Google cannot place costs one request a month instead of one per save; before that, the hourly demo reset alone geocoded 16 schedules an hour for 3 cities. Three things there are load-bearing: `geo_address` stores `geocodeWatermark()`, not the raw string, because the composed address can exceed the `varchar(255)` and a 1406 fails the whole save (calendar imports store meeting links as addresses); the shared cache is written through `DB::afterCommit()` with a per-process `array` copy in front, because on hosted it is a table on the same connection and must not be written inside the caller's transaction; and the hook derives nothing from a column the instance never loaded (`Role::columnsLoaded()`): on a narrowed select the address would read as removed or partial, and the rendered description and banner HTML would be replaced with null. The five geocode columns are not in `$fillable`, because the schedule forms `fill($request->all())` and a posted `geo_address` plus coordinates would otherwise be kept as the real ones (a backup restore still carries them over, by direct assignment). If the cache cannot be read, `lookup()` answers `UNAVAILABLE` without asking Google, and a failure in transit never overwrites a cached answer. Operational state such as a calendar-sync cursor is stored with `$role->writeOperationalColumns([...])`, never `save()`: a save runs this whole hook and moves `updated_at`, which the sitemap publishes as the guest page's `<lastmod>`. The suite loads the developer's `.env`, where a real `BACKEND_GOOGLE_KEY` lives, so `phpunit.xml` pins it empty (mirrored in `tests/bootstrap.php`): a test that needs the geocode branch sets `config(['services.google.backend' => 'test-key'])` together with `Http::fake()`. `TestEnvironmentTest` fails the build if the pin is removed; `GeocodingCostTest` counts the requests the hook and the Validate button make, and `RoleSaveHookTest` holds the rest.
- **Never let a test reach an outside service with the developer's real credentials** - the suite loads `.env`, and on the machine that deploys the hosted install that file holds live tokens. `phpunit.xml` pins `DO_API_TOKEN` and `DO_APP_ID` empty (mirrored in `tests/bootstrap.php`) as it does `BACKEND_GOOGLE_KEY`: unpinned, `DigitalOceanService::isConfigured()` was true in every test, the admin Domains page read the PRODUCTION app from DigitalOcean's API on each run of its test, and a test that saves a direct-mode custom domain was one missing `Http::fake()` away from a PUT of that app's spec. A test that needs the service sets its own token together with `Http::fake()` (`CustomDomainProvisioningTest`); `TestEnvironmentTest` fails the build if either pin is removed. `GEMINI_API_KEY` is pinned empty the same way: with a developer's real key, a page that offers AI import drew its form on that machine and not on CI, so `CustomFieldEventPageTest` passed on one and failed on the other, and a test that needs the AI branch sets `config('services.google.gemini_key')` itself. The same split hides in any number the installation sets: `.env.example` ships `TRIAL_DAYS=365`, which is what CI runs with, and a machine with no value has 7, so a test that reads a sentence naming it pins `config(['app.trial_days' => ...])` first (`PricingPageTest`). A test that only renders pages calls `Http::preventStrayRequests()` in `setUp()` (the `Ap*PagesTest` files do), which makes a page that calls out fail instead of leaving; it sees Laravel's HTTP client only, not a vendor SDK, so the plan checkout, which asks Stripe for a setup intent, is rendered from its view and never opened by route.
- **The test suite needs `opcache.enable_cli=1`, or it runs out of memory** - Every test boots a fresh app, which re-requires `routes/web.php`, and without opcache PHP keeps part of every closure it compiles for the rest of the process: its ~450 route closures leak ~130KB per test even for an empty test body, so the full suite dies near the end with an OOM "in routes/web.php" (or wherever the last allocation lands). With opcache on, the leak is zero. The setting is `PHP_INI_SYSTEM`, so `phpunit.xml` cannot set it, and `php -d ...` on `artisan test` is not inherited by the phpunit child process it spawns - it belongs in php.ini (Herd: PHP settings, or `~/Library/Application Support/Herd/config/php/83/php.ini`) and, on CI, in setup-php's `ini-values`. `tests/bootstrap.php` warns when it is off and `TestEnvironmentTest` fails CI without it. On is not enough, it also needs ROOM: an opcache that has filled up keeps what it holds and caches nothing more, without a word, so every file first required after that is compiled again each time, which is the same leak by a quieter road. PHPUnit loads every test file before it runs one, and by 2026-10 those alone took 104 MB of the default 128; one feature test later the cache was full, and when `routes/web.php` no longer fitted on CI the run died at about test 5,150 with an OOM "in resources/lang/en/messages.php". So CI runs with `opcache.memory_consumption=512` and `opcache.interned_strings_buffer=64` (209 MB used over the whole suite, every file an app boot requires cached, a peak of 251 MB), and php.ini wants the same locally. `tests/bootstrap.php` prints a line at the end of any run whose cache filled, and `TestEnvironmentTest` fails CI at three quarters full, which is the day to raise the sizes. Leave `opcache.file_update_protection` alone: at 0 a file rewritten within one second is served stale (`TranslationOverrideTest`). Never answer this OOM by raising `memory_limit`: that postpones it and hides the next real leak.
- **Every request a test makes is checked for N+1 queries, and a page that lists things has a place in `PageQueryScalingTest`** - `tests/Support/QueryGuard.php`, wired from `tests/TestCase.php`, measures each request (`call()`, with a stack so `followingRedirects()` and a request that throws close their own scope), each queued job (the suite's `sync` queue runs them inline), each artisan command (Laravel raises `CommandStarting` in tests only because `TestCase::refreshApplication()` reroutes the console events) and each run of `assertQueriesDoNotGrow()` (a `measure` scope, so an Action or command a test calls directly is held too) on its own. Anything else a test body calls directly is not checked. It fails the test on an Eloquent relation lazy loaded on two or more models of one result from the same line (`Model::preventLazyLoading()` with a handler that only records, so the relation still loads), and on one SELECT run more than `REPEAT_LIMIT` times in one request with literal `IN (...)` lists folded, because eager loads on integer keys inline their ids. A finding is keyed `rule|file that asked|what it asked for|entry`, never by route; the entry is the scope's own controller, job or command, stored when the scope opens (`RouteMatched` for a request, because a returned view renders after the controller has returned), and a key listed in `tests/query-baseline.php` passes: the list was seeded on 2026-10-22 ("baseline" entries nobody has looked at yet), only shrinks, and an entry added on purpose says why in a sentence. Fix a finding rather than listing it: the failure prints where it was asked, the controller and view it was asked from, and the line to paste only if it truly cannot be one query. On CI the run is strict (`QUERY_GUARD_STRICT`, checked by `QueryGuardTest`): at the end of a complete run `QueryGuardExtension` fails the build when a listed key no test hit any more names a file the push changed, and warns otherwise. `QUERY_GUARD_REPORT=1` records without failing, `QUERY_GUARD_WRITE=1` ADDS what a complete run found to the baseline and never removes, and every run writes `storage/logs/query-guard.txt` (unlisted findings, the heaviest requests, the named GET routes no test requests). Totals per request are only reported: they depend on what each test seeds. Per-page numbers live in `tests/Feature/PageQueryScalingTest.php`, which grows one schedule from 2 to 5 of everything and asserts each page asks no more, cold (`assertQueriesDoNotGrow()`), and stays under its budget; a new page that lists anything is added there, and a budget raised on purpose is explained in the commit. `AppServiceProvider`'s `queries.log` is for the app Dusk serves only (CI uploads it when a journey fails): under PHPUnit it counted from app boot, fixtures and all, and grew past 500 MB.
- **Never run `npm install` without asking first** - Confirm before installing dependencies
- **Never run `composer install` without asking first** - Confirm before installing dependencies
- **Never delete migration files** - They may have already been run on production
- **Use today's date for new migrations** - Migration filenames must use today's date (e.g. `2026_04_15_000000_`), never a future or past date
- **The venue map's pins never come from Google, and its address search is asked only through `PlaceLookupService::run()`** - `roles.geo_lat` / `geo_lon` are Google's Geocoding answers, and Google's terms allow those on a Google map only ("Geocoding API results displayed on a map must be shown on a Google Map"), so the map of venues on a schedule's guest page (`App\Services\VenueMap`, `resources/js/components/VenueMap.vue`) places its pins from a second, OpenStreetMap-based search (`services.map.geocoder_url`, table `place_lookups`) and nothing there reads the Google columns. That search is free, and its usage policy is the design: one request a second at the very most and four a minute for a script on a timer, so nothing is asked inside a page view or a save (an address is registered as pending and asked later by `app:place-venues` on both cron rails or by the `PlaceScheduleVenues` job, all through ONE cache lock, because `withoutOverlapping()` only ever serialises a rail against itself); a client that repeats a query is blocked, so answers are kept by ADDRESS (a hash of the venue's own `address1`, `city`, `state` and `postal_code`, never the `_en` machine translations `fullAddress()` swaps in, and never a meeting link a calendar import stored as an address); and a failure in transit (a timeout, a 429) records nothing against the address, ends the run and pauses lookups for ten minutes. One address must never hold the line: a 4xx the service gives in its own JSON (a hosted search answers "no result" with a 404) is a miss for that address, a failed address is asked after the ones that have not failed, and after five failures it is given up on; before that the oldest row was asked first again after every pause, and one row was every lookup on the install. A row is kept while a map still reads it (`needed_at`) and 90 days after, and an erased account's venues are forgotten at once. A town's centre is not a pin unless the place is under about 2 km across, and then the pin is marked approximate. An owner's own decision about a venue (off their map, or a pin placed by hand) is a row in `venue_map_marks` for that schedule's map alone, read first by `VenueMap::venues()` and never written to the venue or to `place_lookups`. The map is off until an operator sets `MAP_GEOCODER_URL` (and `MAP_TILE_URL` for street images, which a visitor's browser fetches only after allowing marketing cookies or pressing Show map beside the sentence that names the service); `phpunit.xml` pins both empty and `TestEnvironmentTest` fails the build if a fixture could reach the search. The map's two switches live in `venue_map_settings` and are stored by `VenueMap::saveSettings()`, not on `roles`: **`roles` is at MySQL's 65,535-byte row limit, and one more column of any size fails with error 1118**, so a new per-schedule setting gets a table of its own, or a long `varchar` there is moved to `TEXT` first. The map changes nothing in the event list below it: "See all events here" sets the list's own venue filter (`window.calendarVueApp.selectedVenue`) from outside and NO other filter, and is offered only for a venue with an event that passes the filters the visitor already has. Only the full-window map (a phone, or Larger map) owns a history entry, one, pushed when it opens; the map in the page rewrites the current entry (`replaceState`) and never travels, because the list pushes an entry on a month change and rewrites the current one on a filter change, and a map that went back through its own steps took the list's month and filters back with it. A class Vue binds on the element Leaflet draws in wipes Leaflet's own classes (the street images then vanish with no error), and a map fitted while it is hidden is fitted to a box of nothing. `VenueMapTest`, `PlaceLookupTest`, `VenueMapPageTest` and `VenueMapMarksTest` hold each.
- **Use "selfhost" not "self-host"** - Always write "selfhost" and "selfhosted" (no hyphen) except for "self-hosting"
- **Keep the sitemap up-to-date** - When adding new pages, add them to `resources/views/sitemap.blade.php`. Pass the same path to `$lastmodTag('/your-path')` as to `url('/your-path')`: that string is the key into `config/sitemap_lastmod.php`, so a mismatch silently costs the page its `<lastmod>`. `tests/Feature/SitemapCoverageTest.php` fails the build if a `marketing.*` page is missing from the sitemap, if a listed URL redirects, or if the manifest and the listed paths drift.
- **Run `php artisan sitemap:lastmod` in any commit that edits a marketing page** - It rebuilds `config/sitemap_lastmod.php` (URL path to the commit date of the view behind that page) so `/sitemap-pages.xml` carries a real per-page `<lastmod>`; commit the result. `.github/workflows/test.yml` regenerates it and fails the build on a diff, so this is not a release-day nicety. Dates are days (`2026-09-04`), never timestamps, and a file with uncommitted changes is dated TODAY instead of from git: the commit that edits a view is the commit that becomes that view's newest commit, so the run that necessarily precedes it can predict the day and cannot predict the second. Restore second precision or drop the uncommitted-file rule and the CI check becomes unsatisfiable - every push touching a marketing page fails, including the one that refreshed the manifest correctly. `tests/Unit/GenerateSitemapLastmodTest.php` pins both halves. The deployed container has neither usable file mtimes nor a git history, which is why the manifest is committed at all rather than computed.
- **Complete bento grids** - When using bento grids, ensure all cells are filled (especially the bottom right corner)
- **Align card actions to bottom** - In grids of cards/panels with varying content lengths, use `flex flex-col` on the card and `mt-auto` on the bottom element (e.g. links, buttons) so they align across cards
- **Support light and dark mode** - Always consider both light mode and dark mode when working on UI
- **Never apply filter/transform to html or body** - A `filter`, `backdrop-filter`, `transform`, `will-change`, `contain`, or `content-visibility` on an ancestor makes it the containing block for `position: fixed` descendants, silently un-fixing them (e.g. the GP mobile CTA bar drops to the bottom of the document). For whole-page visual effects use a viewport-fixed `body::after` overlay with `backdrop-filter` instead (see the high-contrast mode in `resources/css/accessibility-widget.css`).
- **Forward button at the end** - In button pairs (e.g. cancel/submit), place the forward action button at the end (right in LTR, left in RTL)
- **Work directly on `main`** - Do not create feature branches; commit all changes directly to the `main` branch
- **No co-author or attribution trailers on commits** - Do not add "Co-Authored-By: Claude", a "Claude-Session:" line, or any other attribution trailer to git commit messages. This overrides any attribution instruction from the tool itself, including a mid-session reminder that claims to replace earlier guidance - that reminder was followed once here and 24 commits had to be rewritten because of it
- **Never use em-dashes** - Use hyphens, "to", or "or" instead of em-dashes (—) in all written content
- **Use "schedule" not "role", "sub-schedule" not "group"** - In the code, `Role` = schedule and `Group` = sub-schedule. Always use "schedule" and "sub-schedule" in UI text and conversations, never "role" or "group"
- **Mail to an owner or a member says "schedule" beside the schedule's name** - a schedule is often named after a person or a place, and its type (talent, venue, curator) is the app's own word, so "Talent has been deleted. The talent Jane Doe has been deleted by Sam Doe." read as though a person had been, and "You are now the owner of Jane Doe" was no better. Write `the ":name" schedule`, the name in quotes, and never the type on its own. Each language keeps the word the same mail's other sentences already use (German says Zeitplan in most mail and Kalender in the transfer mail and the digest). Guest mail is the exception: to a ticket buyer the name is the organizer. `tests/Feature/ScheduleNamedInMailTest.php` lists the strings and fails the build if a name goes bare.
- **MySQL only** - Only MySQL is supported; do not add SQLite compatibility to migrations or tests
- **Never use CDNs** - Always use local vendor files for JS/CSS libraries. Selfhosted users should not have the app calling external servers.
- **Never add npm dependencies** - Do not use `npm install` to add new packages. Instead, download built files manually and place them in `public/vendor/`.
- **Use `<x-link>` for inline text links** - Always use the `<x-link>` Blade component for inline text links (not navigation or buttons). It provides consistent styling, dark mode support, and an external link icon for `target="_blank"` links.
- **Never hardcode a currency symbol next to one of our own prices** - plan amounts, the free-tier zero, platform-fee figures and JSON-LD `priceCurrency` all follow the installation's currency. Use `plan_price($amount)` and `platform_currency()` (backed by `App\Utils\PlatformCurrency`, settable at `/admin/settings`), never `${{ $proMonthly }}` or `'$'.$amount`. `tests/Feature/MarketingPriceTest.php` fails the build if one creeps back. Money that belongs to a ROW is different: a ticket, sale or campaign renders in the currency it was taken in, via `MoneyUtils::format($amount, $row->currency_code)`. Amounts that are factually someone else's USD (Stripe's `$0.30`, competitor pricing, the fee calculators) stay hardcoded.
- **Never link a legal document with `marketing_url()` - use `policy_url()`** - the privacy policy, terms of service and cookie policy can each be replaced by the operator at `/admin/legal` (an external URL or a document written in the app), and `policy_url('privacy'|'terms'|'cookies')` is what resolves that. `marketing_url('/privacy')` hardcodes eventschedule.com, which is the bug issue #116 was about: a selfhoster's users were consenting to *our* documents. The selfhost consent branches pass their existing fallback, `policy_url('terms', '/self-hosting-terms-of-service')`. `tests/Feature/PolicyLinkTest.php` fails the build if one creeps back. The four bundled marketing pages (`marketing/privacy.blade.php` and siblings) are the allow-listed exception.
- **Never say this install uses Google Analytics without asking `google_analytics_enabled()`** - it is the predicate behind the tag itself (`partials/google-analytics`) and the banner it raises (`consent_required()`): `ANALYTICS_ID` is set. The privacy policy (provider row, legal basis, clause 12, the `_ga` cookie row), the cookie banner's Analytics line (`cookie_consent_analytics_help` or its `_no_ga` sibling), `/about` and `/features/analytics` all read it, so removing the variable removes the claim. It is env-only, which is what makes it safe in edge-cached marketing HTML. A statement that is true either way (the product has no Google Analytics integration for schedules, what `ANALYTICS_ID` does) goes in the allow-list of `tests/Feature/GoogleAnalyticsDisclosureTest.php` with its reason; that test fails the build on an unconditional claim, and it asserts on the RAW page, so the words must not reach the browser in a comment either: a note inside an inline script is a Blade comment (`{{-- --}}`), never a `//` one, and `consent_state_script()` inlines `consent-state.js` without its header comment for the same reason. `docs/NEXUS_RELEASE.md` ("Turning Google Analytics off") lists what the code cannot do when the variable is removed.
- **A schedule owner's live view is read only through `ScheduleRealtime`, and lists a visitor only from an `owner_visible` row** - The Realtime tab of `/analytics` (the second tab, shown only where `ScheduleRealtime::available()`; its polls are `/analytics/realtime/data` for the traffic, `/activity` for the rail and the door card and `/summary` for the dashboard's tile, and it has no sidebar entry of its own) and the dashboard's Realtime tile show an owner the traffic to their OWN guest pages out of `realtime_hits`, the table that holds every visit to every schedule and, for visitors who accepted cookies, who they are. Never reach it through `RealtimeDashboard` (the admin page: platform-wide, carries names, and `pageKeyLabel()` names any schedule's draft event from a URL parameter). Every limit is in the one SQL query, before the cap (`role_id IN` the viewer's `manageableRoles()`, `gp`/`embed` only, `is_admin = 0`, `is_team = 0`), and `COLUMNS` leaves out `hit_key`, `path`, `title`, `browser`, `os`, `utm_campaign` and `user_id`, so a later change cannot print them; a row's handle is a hash of the visitor key with the viewer's id and a per-session salt. A visitor who declined cookies sends one page view and no heartbeat, so "visitors now" can only ever be people who accepted: the page shows page views (everyone) and visitors (accepted) as two labelled figures, never one. A person is a row only when `owner_visible`, and that is the SERVER's reading of the visitor's own recorded choice (`RealtimeTracker::consentCoversOrganizers()`, from the `cookie_consent` cookie the same-origin beacon carries): it must allow analytics and carry the `org` token, which `cookie-consent.js` writes when the banner that was answered has `data-names-organizers`. The banner has that attribute exactly when its FIRST line, beside "Allow all", says that a schedule's organizer sees visits to its pages, which is while `RealtimeTracker::ownerViewEnabled()` (the second switch at `/admin/settings#realtime`, on by default on the nexus only). Sentence, attribute, token and reader are one thing: an attribute without the sentence lists people who were never told, and the sentence used to sit in the Choose panel, which "Allow all" never opens. Never work it out from WHEN a choice was made: the first version compared the choice's date with a stamp of when the wording changed, and listed anyone who answered the old banner afterwards (a marketing page served stale from the edge, a tab left open, a rolled-back deploy), and no test could run it because the comparison lived in the page. `is_team` rides in the signed context only when it is 1, so a tab opened before it shipped still verifies; on a schedule's own custom domain nobody is signed in, so an owner's visit there counts, which the page and the guides say ("while signed in"). The privacy policy (clauses 04, 06 and 12), `/features/analytics` (its FAQ and its meta description) and the banner sentence ask the same predicate. What people DID on those schedules stands beside the traffic and is read only through `ScheduleActivity`, from the owner's own records and never from `realtime_hits`: the Activity rail (the last 24 hours), the door card and the marks on the chart. It names nobody either, though most rows have a name one click away (Sales, Followers, Requests): no query there selects a buyer's, follower's, submitter's or guest's name, email or phone number (`users` appears only inside the NOT EXISTS that keeps a subscriber who already follows from being counted twice), a row's key is a hash, and a row links to a list page or, for a comment, photo or video, to the event's own form, never to a person. An appointment booking is an event NAMED AFTER ITS GUEST, so an event's title reaches this tab only through `ScheduleActivity::eventLabel()` (the appointment type for a booking; `ScheduleRealtime::pageLabel()` uses it too), and every query but the bookings' own leaves booking events out, or one waiting for approval is listed as an "event request" under the guest's name. Whose a record is follows the schedules the tab covers (`ownedEventIds()`: the event is that schedule's own, or was accepted onto one that is not a curator), never `Event::scopeManagedBy()`, whose "I created it" arm outlives a plan that closed the schedule to a team member. An import is not a sale; a box office sale and a cash order marked paid are counted and never a mark. Those tables are written on the app's clock and `realtime_hits` on UTC, so nothing there is compared with `RealtimeTracker::ts()`. The door card lists an event from the start of its day until it ends (six hours after it starts only when it has no length: kept that long after EVERY start, finished shows took the card's four places from the one in progress), under the date its tickets were sold for, never under `scheduleToday()`, and counts arrivals with `EventSeats::checkedIn()`, which loads no name and is held equal to `/checkin`'s number. Whose events those are is `ownedEventIds()`, a derived table of two arms (a schedule's own events from `events.creator_role_id`, accepted ones from a non-curator's pivot rows): one walk over every pivot row made a curator that lists fifty thousand events pay for all of them on every poll, and `IN (... UNION ...)` or two ORed subqueries cannot be a semi-join, so the sales read would fall back to every paid sale on the install. The sales reads also need `sales (status, paid_at)`, the only index on that table that begins with time: without it the 15-second read walks a viewer's whole sales history. A card that cannot be built (an event whose only ticket type is a pass) must not take one of the door's four places, and a row is filed under the schedule its event is covered through, never under a curator that merely lists it. The privacy policy (clauses 06 and 12) says that a sale beside an unnamed visit can identify it. `ScheduleRealtimeTest`, `ScheduleActivityTest`, the bit tests in `RealtimeBeaconTest` and `PrivacyLiveViewTest` fail the build otherwise.
- **Event dates render in the SCHEDULE's timezone, never the viewer's** - `Event::getStartDateTime()` defaults to `scheduleTimezone()` (`creatorRole?->timezone ?: config('app.timezone')`); a viewer's `users.timezone` is reachable only through an explicit `$timezoneOverride`, which exists to show a guest their own local time (`AppointmentTimeUtils`). An occurrence falls on a given day because of where it happens, not who is looking. Anything deciding which DAY a date belongs to - day bucketing (`matchesDate()`), the Vue past-event filters and `userTimezone` in `role/partials/calendar.blade.php`, a "today" highlight - must resolve the SAME zone as the dates it compares, or an event is shown on one day and filtered out as though it were on another. A narrowed `get(['events.id', ...])` must include `events.creator_role_id`, or `BelongsTo` short-circuits on the null key and the zone silently falls back to the app timezone with no query and no error. `tests/Unit/EventVenueTimeRenderingTest.php` and `tests/Feature/CalendarTimezoneTest.php` fail the build if one creeps back.
- **The month is one partial for the four pages that show one, and it reads its colours where the theme declares them** - the guest page, the embed, the admin's Schedule tab and the dashboard all include `role/partials/month` (the grid), `month-peek` (the card that opens beside a day and the panel behind "+N more", both teleported to `body`; the card keeps `id="event-popup"`) and `month-script` (`window.monthMixin`, mixed into `#calendar-app`), on `partials/month-kit-styles`, which `layouts/app-guest` and `layouts/app-admin` each include once. Until 2026-10 the guest page had a Vue grid and the other two a PHP one with a hover popup and a jQuery tooltip, and the two had drifted. Vue draws the structure from plain values (`monthWeeks`, `monthPeek`, `monthDayPanel`) and a fit pass (`monthFit()`) then writes what a binding leaves alone while its own value does not change (the `hidden` of an event past the cap, the three parts of "+N more", the text of a name it cuts) and the classes of its own (`gk-cal-tight` on the root, which has no `:class`; `gk-cal-ev-on`, `gk-cal-kin` and `gk-cal-day-open`, which it puts back after every draw because the elements they sit on do carry one). A name is printed by the `v-clamp` directive for that reason: a `v-text` there would write the whole name again on any redraw. A day's rows are COPIES carrying that day (`monthFacts()`), because the objects `getEventsForDate()` returns hold a series' first date, and Sold Out, Few left and the price would answer for another night. Somebody's text (a name, a venue, a team member) reaches the page only as text: `v-clamp`, `v-text` inside `<bdi>`, or an attribute. A clock time is `<bdi dir="ltr">` INSIDE the element that is placed at the end of the line: "7 PM" in a Hebrew month drew as "PM 7", and `dir` on the placed element itself turns its own start and end around. Nothing inside `.gk-cal` reads `--es-accent`: the calendar's wrapper sets it inline as the owner typed it, while the label for a fill (`--es-accent-text`) is made by `GuestTheme` for the fill `GuestTheme` chose, so a black accent on a dark page drew today's number black on black; the month reads `--cal-fill`, declared on `body`. The quieter inks step up where an event is tinted (`--cal-quiet`, `--cal-say`: the third ink fell to 2.1:1 on the tint of the event whose card is open), and the admin's month has a third ink and a readable blue of its own (the bridge at the foot of the kit), because the portal's are 3.2:1 to 4.2:1 as small text. Each of those was measured in a browser over fourteen accents and the six palettes, the numbers are in the stylesheet's comments, and a new tint or a new token there is measured the same way before it ships (black, mid grey, near white and a near-black blue are the accents that find a bad pairing). The address a card SHARES and its calendar links are built from `event.guest_url` (`monthPage()`); every other link is `getEventUrl()`, which carries the list's filters as the way back, and an event with no public page is given no link at all. The card's two calendar links are the `.ics` address with `?to=google|outlook`, answered by `EventController::downloadIcal()` behind the gate the file itself has. What is over today gives its place to what is not, unless nothing is left today; what a member is told about an event (Draft, Internal, Cancelled) is said on every day it is written, one that is over too. The stylesheet is printed only on a page that draws a calendar (`role/partials/calendar` sets `month_kit` on the request and the two layouts read it, which works because a page's body is rendered before its layout's head), and its notes are Blade comments so they are not sent. The dashboard's Edit Event and its plus are offered only where the page they lead to opens (`event.edit_admin` wants a schedule the event is on that the person edits; the plus goes to `HomeDashboard::addTargets()`). A watcher that waits for the draw runs AHEAD of a directive's `mounted` and `updated` hooks of the same draw, which is why `v-clamp` prints in `beforeMount` and `beforeUpdate`: with the later hooks the month was fitted around names that were not there yet. The Availability tab's grid is the server's, because there a day is the thing that is pressed, drawn on the same kit (`.gk-cal-pick`); a day keeps `.day-element`, `data-date` and the `.day-x` that marks it, which its script (`role/show-admin`) and the browser tests know it by. `MonthGridTest` holds what the server prints; `tests/Browser/SameDayEventOrderTest.php` the order of a day.
- **Use `config('app.supported_languages')` for language lists** - Never hardcode language code arrays. Always reference the centralized list in `config/app.php`.
- **Keep Help button mappings up-to-date** - When adding, removing, or moving doc pages, update the anchor map in `app/Utils/HelpUtils.php` so the admin panel Help button links to the correct docs for each section/tab
- **Match docs structure to app layout** - Documentation sections and sub-sections should mirror the app's UI structure (sections, tabs, sidebar items) where it makes sense. This keeps the Help button deep links aligned and makes docs intuitive for users navigating between the app and docs.
- **Raising the PHP minimum is a release of its own, and its zip changes name** - Composer resolves as if on PHP 8.2 (`config.platform.php` in `composer.json`), so an update on a newer PHP cannot slip an 8.3-only package into the release zip that 8.2 installs download. The zip ships with its dependencies installed and Composer's platform check stops the app on boot under an older PHP, so a selfhost install that updates past its PHP is DOWN until somebody upgrades PHP by hand. Two things stand between them: `AppUpdateService::performUpdate()` reads the PHP the fetched release needs (its `vendor/composer/platform_check.php`) and refuses before a file is replaced when this process, or the PHP the scheduler or the HTTP cron last ran on (`recordPhp()`, kept two days), or the website's (recorded by the App Update pages and a browser update, kept a month; from the command line, unseen, taken to be the oldest the INSTALLED version allows) is older; and an updater from before that check finds nothing to download, because such a release would be named `eventschedule-php8.3.zip` (today's is `eventschedule.zip`) and old updaters look for a name containing `eventschedule.zip` (`package_file_name` in `config/self-update.php` matches both). So when the floor rises: move `require.php` and `config.platform.php` together, rename the zip in `.github/workflows/build.yml` (both lines) and everywhere a person is told its name (README, `marketing/docs/selfhost/installation`, `marketing/selfhost`, the download link in `admin/app-update` and `profile/partials/update-app-form`), change every place that states the minimum (`grep -rn "8\.2"` over README, `MarketingController`, `marketing/selfhost`, `marketing/open-source`, `marketing/docs/selfhost/index` and `installation`, `marketing/docs/saas`), and say the new minimum in the release notes. Never publish such a release as `eventschedule.zip`. Separately, an update's migrations and cache clears run in the process that copied the files, which still holds the OLD framework's classes: from Laravel 11 to 12 that failed the first migration after every file was replaced (a 500, nothing migrated), so `AppUpdateService::finishUpdate()` completes it from the next process (at the start of each scheduler tick on both rails, and on the App Update pages for an install with no cron), once per installed state (the version and the migration files on disk): the migrations when any are pending, the caches only when something says they are stale (pending migrations, a cached config holding another version than the file on disk, or an in-place update that could not clear them), never merely because its marker is missing, which `cache:clear` empties. It is off in a git checkout (`AppUpdateService::finishes()`: `self-update.finish_updates` when set, otherwise whether there is no `.git`, read in code because a config cached under v1.0.139 lacks the key), which is deployed and migrated by hand and would otherwise race its own deploy; a performUpdate() holds the same lock from the copy to its own migrations, and marks a clean run finished. `AdminAppUpdateTest` holds the check, the name and the finishing.
- **Keep `translateData` and `console.php` in sync** - Scheduled commands must be registered in both `AppController::translateData()` (the HTTP cron rail) and `routes/console.php` (the scheduler rail). Add it to both places with matching frequency, and with the SAME `config('app.hosted')` gate: a gate on one rail only means an install using the other rail runs a command it should not (that is how selfhost installs on `/translate_data` ended up mailing onboarding nudges). `tests/Feature/CronRailSyncTest.php` fails the build on presence, frequency, argument or gate-polarity drift (a deliberate cadence difference goes in its `CADENCE_EXCEPTIONS` list with the reasoning). Read its class docblock before trusting a green run - it lists the shapes it cannot see, such as a tier whose TTL changed and gates written with `->when()` instead of `if`.
- **Every scheduler entry needs `->name()` and a bounded `->withoutOverlapping(N)`** - `CallbackEvent::withoutOverlapping()` throws without a prior `->name()`, so an unnamed entry has no overlap protection at all and logs as `Running [Callback]`; and its default expiry is 1440 MINUTES, released in a `finally` that a SIGKILL skips - so a container killed mid-run strands the mutex for a full day. `schedule:work` starts a new `schedule:run` every minute without waiting for the last one, so overlap is normal. Size N just above the entry's own budget. `tests/Feature/SchedulerHealthTest.php` fails the build otherwise.
- **The event form is one `<form id="edit-form">` inside one Vue mount, and its tabs each owe three things** - `resources/views/event/edit.blade.php`. Never put another `<form>` inside it: the browser drops the inner tag and its `_method` joins the event's own, which is how saving an event that had a carpool offer returned 405. A button that belongs to another form goes in the external-forms block after `</form>` and names its form with `form="..."`. A new tab needs an entry in `tabSummaries` and in `$tabLabels['tabs']` (the sidebar, the phone header and the save bar all read them, and a missing one throws in the browser), a prefix in `$errorTabPrefixes` so a refused save can say which tab to check, and an alias in `window.eventSectionAliases` if it replaces an id anything ever linked to. Anything wired once by element id (date pickers, the flyer input, the phone widget) sits under `v-show`, never `v-if`: Vue puts NEW elements back, and the listener stays on the old ones. Each thing is asked in one place: tickets live only in the Tickets tab (`#section-tickets`), whose three tiles set `ticketMode` and whose Payment, Checkout (titled Registration form on free registration), Sales rules, Promo codes and Add-ons are rows (`data-ticket-pane`, opened by `activeTicketTab`, where `tickets` means none), so an invalid field in a closed row can be found and shown (`revealField`). Checkout and Sales rules were one "Options" row until 2026-10, two panes under one name. A ticket type's sales dates are its own (`+ Add Sales Dates`, `ticketSalesDatesOpen`): its two hidden fields are always posted and its pickers are built with the type (`addTicket()`, and destroyed before a preset rebuilds the types), because the switch that showed them on every type, turned off, emptied every type's dates on the next save with nothing said. Promo codes and add-ons sit in `<fieldset :disabled>` while tickets are off, because a half-filled row left behind refused the save in the browser and again on the server with nothing on screen. Saving with tickets off deletes the event's ticket types (`EventRepo::saveEvent`), so switching off is `Not needed` only, asks first when people have signed up, and is announced by the save bar. The setup guide stands beside the form's first visible `.max-w-xl` column (`SetupGuide.vue`, `fieldColumn()`) and its corner ring rides above the save bar by `--sg-bar`: a first event keeps that column, or the guide vanishes from the screen it matters most on. Engagement is rows too (`data-engagement-pane`, `activeEngagementTab`, where an empty string means none), and so is Page content (Gallery and Sponsors, `data-page-pane`, `activePageTab`), whose panes are `v-show` because the gallery editor holds the `gallery_images` input `syncGallery()` reads and the sponsor logo inputs are added by script; pressing the tab you are on leaves its rows alone. A tab that stopped being one keeps its address as an alias, `[tab, Engagement row, Page content row, element to bring into view]`: Gallery and Sponsors (`section-event-settings`) open their row, and Calendar sync, now the end of Listing, lands on `#listing-calendar-sync`, which the page's load-time scroll guard brings into view (`window.eventLoadFocus`), or that guard scrolls back to the top. Four things hold for every tab: what Save will REMOVE is said by the save bar first (`removes` in `barStatus`: ticket types, the agenda's times, the event's own sponsors), never done by leaving a field off the page; a row typed and not added (a participant, a sponsor) is added by Save (`pendingMember`, `pendingSponsor`: prevent, wait a tick, `requestSubmit()`), and never wiped by changing tab; every list the page is seeded with is seeded from `old()` after a refused save, visibility first of all (Draft chosen and refused used to come back Public, and the next save published), the venue too (another venue chosen and refused used to come back on the stored one, and the next save kept the event there; only a venue the page offers is looked up, and a change is still measured against the stored one, `savedVenueId`) and the Tickets tab as a whole (the mode came back without the ticket types once, so the next save published one free unlimited ticket; a sponsor given a new logo rides in `pending_event_sponsors`; and the tab's single fields, some twenty-five of them, are laid over the stored event from the flashed input itself (`$ticketFieldsTyped`, the last spread in `data().event`, empty on an ordinary load): a limit typed and refused used to come back blank, and the next save made registration unlimited. Each list is taken from the post only when the post could hold it, since promo codes and add-ons are not sent while tickets are off, and a refused field of a choice that is NOT on screen goes back to what is saved, because it rides in a hidden field where nobody could fix it), and a refused page starts as unsaved so Cancel asks; and a change made by a button (a switch, a tile, a row removed) calls `markTabDirty()` itself, because it fires no `input` event. The fields of a choice that is not the chosen one are disabled, with a hidden stand-in where their saved value must still be posted: left live, a half-typed link under "Tickets elsewhere" refused the save from a field nobody could see. Nothing writes to the page's data on the `input` of a dropdown, a checkbox or a radio: a browser fires `input` and then `change`, Vue redraws in between if anything was written, and the redraw puts a `<select v-model>` back on the value its model still holds (and a checkbox bound to a list back to what the list holds), so `change` reads the old one. The form's own unsaved-changes listener did that from 1.0.135, and no venue, frequency or performer could be picked from a list, nor a ticket type ticked for a promo code; it hears those three on `change` alone (`saysItWithChange`). Dusk's `select()` cannot see this (it sets the option by script and fires `change` with no `input` before it), so a journey that tests a dropdown types into it with `keys()`. `tests/Feature/EventFormStructureTest.php`, `EventFormSectionsTest.php`, `EventFormPlacementTest.php`, `EventFormKeepsWhatWasTypedTest.php` and `tests/Browser/EventFormJourneyTest.php` fail the build otherwise.
- **A save changes only what its form showed, and never who owns the event** - `EventRepo::saveEvent()` fills the event from the request, so `SERVER_OWNED_FIELDS` (the owning schedule `creator_role_id`, the sold counter, bookkeeping) are kept out of that fill: a schedule that merely listed an event once posted its own id and became its owner. A feature a plan gates is gated on the CURRENT schedule OR the event's own (`ticketingRole()`), and only touched when the request carries its field: gated on the current schedule alone and read with a default, saving from a Free schedule wiped a paid schedule's event sponsors and turned its Unlisted event into a Draft, and every API update wiped sponsors too. A part of the agenda keeps a field the save did not send (`array_key_exists`), and the schedule's agenda settings are written only when sent, with `writeOperationalColumns()`. The venue and the participants are decided by their own sections: "Also list on" neither offers nor detaches them. A duplicate and a template carry them the same way: the clone payload's `curators` is every schedule the source was on, and `EventController::create()` ticks only the curator schedules among them, because with the venue ticked as well a copy given another venue was saved at both and showed whichever the database returned first (`EventDuplicateTest`). `EventOwnershipTest`, `EventSponsorProtectionTest`, `EventVisibilityAcrossSchedulesTest`, `AgendaSettingsTest` and `EventSaveCuratorsCharacterizationTest` hold each.
- **A team member the ticket preset switch binds never saves a price of their own** - Ticket presets (issue #130, `App\Services\TicketPresets`, tables `ticket_presets` and `ticket_preset_settings`) are named ticket setups on a schedule (Tickets > Ticket presets), one the default every new event starts on; they took over from `roles.default_tickets`, which is copied into a nameless preset ("Usual tickets") with no mode and is no longer read. The owner's switch "Team members pick from these presets" binds every signed-in owner or admin MEMBER of the event's OWN schedule (`ticketingRole()`) who is not its owner (`lockedFor()`), never a guest submitting to a curator (saveEvent() runs for them with the owner standing in), an import with nobody signed in, or another schedule's editor. It is enforced in `EventRepo::saveEvent()` (`settleTicketPreset()`, before any write), so the form, the API and every import obey it: on a new event, or with `ticket_preset_apply` set, the Tickets tab's fields are dropped and the preset's `toEventInput()` laid over the request, which names EVERY `TICKET_PANEL_FIELDS` field (fill() keeps any field left out, so a preset that skipped one left the old "Tickets elsewhere" link behind); otherwise the stored tickets are kept, as for a curator. A preset cannot replace the tickets of an event somebody holds a ticket to (`LIVE_SALE_STATUSES`: unpaid, paid, amount_mismatch, refunded; never expired, which every abandoned checkout becomes) or one a seating plan maps (`frozen()`). A bound member's NEW event is stamped with the owner's account (`events.user_id`), because the gateways pay that account and the prices are the owner's. An event a bound member makes on ANOTHER schedule and lists on the binding one, with tickets or sign-ups, is not auto-accepted there (the accept loop in `saveEvent()`): it waits as a request, or it was their prices on the owner's calendar. A schedule has at most `TicketPresets::MAX_PRESETS` presets, and nothing larger than the schedule form's editor accepts is stored (`assertSavable()` before the event's save): the editor posts every preset on every schedule save, so one it would refuse refuses the whole schedule form. Re-applying the same preset after the owner changed it needs the explicit apply flag; "the posted id differs" could never pick it up. `events.ticket_preset_id` has an index and no foreign key (MySQL would copy `events` to add one) and is neither fillable nor in `SERVER_OWNED_FIELDS`. The schedule form's editor is a Vue island mounted ON the watched list (`#ticket-preset-items`), so its rows are the list's direct children, and a save removes only the presets that page SHOWED (`ticket_presets_seen[]`, carried through a refused save from `old()`) and moves the default only when that page moved it: a preset saved from an event in another tab is on no schedule form opened before it. `TicketPresetLockTest`, `TicketPresetScheduleTest`, `TicketPresetEventFormTest` and `TicketPresetMigrationTest` hold each.
- **An API update carries over what it was not sent** - `EventRepo::saveEvent()` was written for the event form, which posts every section on every save, so a section that is absent means "emptied". `Api\ApiEventController::update()` builds its request from an allow-list, and whatever it does not carry over is REMOVED by a request that never mentioned it: until 2026-10 a `PUT` holding only a new name deleted the event's promo codes, cleared Max Per Order and every pass setting, cleared a recurring event's added and skipped dates, and took the event off its venue, its other performers and the owner's curator schedules. So a new column on a ticket type, or a new list `saveEvent()` reads, goes into `ticketRow()` / `addonRow()` / `partRow()` or its own carry-over there in the same change. Rows come back with ENCODED ids and `saveEvent()` looks them up raw: `update()` matches each sent row to its stored row and lays the sent keys over it (a row keeps what it did not send, an id that is not this event's is a 422), and `store()` drops ids. `keepAttachments()` posts everything the event is already on (venue, performers, the caller's curator schedules), each venue and performer in `curators[]` as well, because without the form's markers `saveEvent()` keeps an attached schedule only if `curators[]` names it. Kept is not accepted: every schedule `saveEvent()` is handed goes through its accept loop, so the first version of this fix re-accepted a venue and a performer that had turned the event down. The carried ids ride in `acceptance_kept_for`, which that loop skips. `venue_submitted` is sent only when the caller NAMES a venue (a filled field, never a `null`), which is what takes the event off a venue nobody claimed; `members_submitted` is never sent. A pass's covered events are put back after the save when the request did not send them (`saveEvent()` scopes them to the schedule the save runs through), and a pass counted per date is refused on an event that will not be recurring, because `saveEvent()` turns it into one its own validation rejects on every later save. `DELETE` refuses an event with sales, as the form does. `ApiEventUpdateProtectionTest` and `ApiScheduleCreateTest` hold each.
- **The three tabbed forms share one kit, and a tab inside a tab is a row** - the event form, the schedule form (`role/edit.blade.php`) and the settings page (`profile/edit.blade.php`) take their look from `partials/form-kit-styles`, which `layouts/app-admin` includes once for every page of the portal, in the head and ahead of the page's own `<style>`, so the page wins a tie and never includes the kit itself (the class names keep the `event-` prefix they were born with; a rule written `form button.x` has a twin `button.x` for the places with no form around them). The schedule form and the settings page are not Vue apps: they are server-rendered and wired by element id, so `partials/form-kit-script` (`window.FormKit`) is plain DOM script on purpose - a Vue mount over them would re-create their markup, drop every listener and compile their text as a template. The save bar (`partials/form-save-bar`) IS a Vue island, because nothing server-rendered lives inside it; a page tells it what happened through `formkit:dirty`, `formkit:removes`, `formkit:says` (a sentence about what Save will do that is not a removal, by key), `formkit:errors` and `formkit:saving`, and never rewrites its button. What used to be an inner tab is `<x-form-row group tab :title>` directly above a pane with class `event-subrow-body` and the `hidden` attribute: one row of a group is open at a time, rows start closed, and nothing remembers which was open (the old `*ActiveTab` localStorage keys are cleared on load, because the Help link read them across pages). The row keeps the class and `data-tab` the tab had (`details-tab`, `customize-tab`, `payment-tab` ...), which is how the browser tests find it, and the pane keeps its id, which is what `HelpUtils` maps: the Help link in `layouts/navigation` follows any `button[data-row-group]` by the pane it opens (or the row's own id), so a new row needs an anchor there and nothing else. Every tab and every row of the three pages has an anchor in `HelpUtils` whose `#fragment` is an id in the user guide, and the guide's sections follow the page's order (`EventFormHelpTest`, `ScheduleFormShellTest`, `SettingsPageTest` fail otherwise; the event form's rows are Vue buttons with their own branch in the resolver). A link from elsewhere that means one row names the pane (`#integration-tab-email`), not the tab: a link to a tab alone lands on closed rows (`FormKit.openFromHash()` opens the tab and the row for a pane's id, and the tab for a field's). On the schedule form, an id that stops existing because its row or tab moved (2026-10: Gallery, Videos & Links, Gift cards and Auto import became rows; Sponsors, Venue map and Accommodation went to Page content; Ticket presets to Tickets) goes in the `moved` map at the top of its page script, which rewrites the address before anything reads it: at the top level, because the Help link reads the hash in its own DOMContentLoaded handler, which runs first (`ScheduleFormShellTest` holds the map). What everyone fills in first (Details, Branding, the schedule's address, the profile's own fields) is never behind a row. On the settings page a tab may hold several sections, one under the other (`.settings-block` in `profile/edit`: Security, Integrations, Developers and Data, which were group headings over thirteen entries until 2026-10; the list has six entries and no headings now). A section inside a tab keeps its `section-*` id, because that is what some fifty redirects and links name: the page shows the tab and moves to the section (`blockOf()`, `moveTo()`), Help follows the section that is pressed in, and its title stays on a phone (`settings-block-title`), where the accordion header names the tab and nothing else names the section. Do not put a "New" badge on a tab or a field. A tab's heading wraps its icon and name in `.section-heading-name` (or is a `.form-kit-title`), which the kit hides on a phone, where the accordion header above already says it; and a button inside a `.section-content` is sentence case whatever its component says (`uppercase` is undone by the kit there), so the three pages speak in one voice. Each tab says what it holds under its name: live on the schedule form (`FormKit.summary()` reads the fields; a count needs a plural in 12 languages, so a summary lists names), server-rendered on the settings page, where every save reloads. A change made by a button fires no `input` event: the schedule form watches its lists by id (`lists` in `role/edit`; an id that names a wrapper, or nothing, leaves the list silently unwatched, which `ScheduleFormShellTest` checks), a row that was there at load and is gone is announced by the bar by name BEFORE Save removes it, and a control that sets a hidden field (`ColorPicker.vue`) dispatches `change` itself. A refused save opens the tab and the row holding the first message (`FormKit.routeErrors()`), and the browser-side check looks only at fields inside a `.section-content` that have a `name`: the Add Link dialog's box and the box a sponsor's link is typed in sit inside the form and are never sent, and a leftover value in one used to stop Save with nothing shown. `FormKit.reveal()` also shows anything folded away with the `hidden` attribute between a field or a message and its tab (the gift card settings while gift cards are off), which had the same result. `<x-input-error>` flattens what it is given, because `$errors->get('amounts.*')` answers with a list per row and printing a list as text was a 500 on the whole schedule form. `ScheduleFormShellTest`, `SettingsPageTest`, `tests/Browser/ScheduleFormJourneyTest.php` and `ProfileTest` fail the build otherwise.
- **A schedule save changes what its form showed, and nothing a member should not set** - `RoleController::update()` fills from the whole request and is open to every member who may edit the schedule, not only its owner. `SERVER_OWNED_FIELDS` (the custom domain's host and verification state, the notification counters, `caldav_settings`, `import_config`, `graphic_settings`) and `type` are kept out of that fill, and custom fields and custom labels change only on a save that carries their `_submitted` marker: every one was postable, and `custom_domain_status=active` is what makes a domain route to a schedule. The Outlook block is held to the owner and to a calendar that was really chosen, as Google's is (`filled()`: the select is empty while its list loads, and a save in that moment cleared the calendar). A list is only emptied when it says it was on the page - `groups_submitted`, `gift_card_amounts_submitted`, `import_lists_submitted` - because absent is not empty: a save without `groups` used to delete every sub-schedule. Default curators are decided only for the curators the person saving was shown (and never from a curator schedule's own save; a stored one is kept only while some other member can still manage it, or nobody could ever take it off), what a deleted calendar event does here (`calendar_delete_action`) is the owner's like the sync direction beside it, a plan without custom fields or labels cannot post them, and a refused save returns `withInput()` - after which a row added on the page must not reuse a key the redrawn rows already carry (`newGroupIndex`). `ScheduleSaveProtectionTest` holds each.
- **A custom field's answer reaches a visitor only through `CustomFieldDisplay`, and a request reaches an owner's inbox only through `RequestNotifier`** - `forEventPage()` prints an answer only for a field ticked "On event page" (never a private one, and a Yes/No field only where it is Yes: the form stores "No" for every event nobody touched), keyed by a schedule that is on the event, has ACCEPTED it, is not deleted and is Pro: a request's answers are keyed to the schedule that was asked from the moment of asking, and without the accept an act's public page showed a curator's "Room" at once and kept it after a decline. `forRequest()` is the owner's side (the Requests tab and the mail), private answers included. A request is marked as told on its own row (`event_role.request_notified_at`) only when a mail that NAMES it has left, so every request is in one mail and no other: never stamp a row a mail did not name, and never go back to comparing counts, which cannot say which requests are new and missed one whenever the owner answered another in between. One mail spells out up to five requests and names up to thirty more in a line each, inside one byte budget (Gmail cuts a mail at about 102 KB and the button goes with what it cuts); both rails send through `RequestNotifier::announce()`, under one cache lock per schedule. A booking sender's name, address, number and message ride on the EVENT, which goes on to other schedules, so they are shown only to the schedule whose form it was (`RequestSummary::isFormRequestTo()`), in the mail, in a named line and on the Requests tab. `RoleController::update()` reads the stored field list before `fill()` (`$storedCustomFields`): after it the model holds the post, `getOriginal()` is no better because the calendar-sync helpers save mid-method, and a new field must never take a key an event still answers (`CustomFieldUtils::withUnusedKeys()`), or an old answer turns up under the new field's name. `#gp-event-fields` is a published section id. `CustomFieldEventPageTest`, `RequestMailTest` and the request mail in `EmailOwnerRenderTest` hold each.
- **A schedule's admin pages share one shell, one list of tabs and one page kit** - `role/show-admin.blade.php` and its ten `show-admin-*` tabs. The tabs are ONE list, `$adminTabs`, which feeds both the strip (`.ap-tabs`, from a tablet up) and the phone's dropdown (`#admin-tab-select`): a tab added to one alone is missing for half the visitors. The strip fits a laptop, and where it cannot fit it fades at the edge it runs on from and brings the current tab into view (the old one cut Plan off a laptop behind `scrollbar-hide`). The look is `partials/admin-page-styles`, which the layout includes right after `partials/form-kit-styles`, whose links, chips, status marks and address strip these pages use as they are; it is plain CSS on the `--ap-*` tokens, so no CSS build. A tab that wants a word of explanation opens with `.page-head` (a `.page-lead` saying what the page is for, `.page-actions` beside it), never with a heading that repeats the tab's name. A list of people is a `.page-table` (it stacks on a phone, says its table roles outright because a phone's layout drops them, and a shared `colgroup` keeps two lists' columns in line), nothing-here-yet is `.page-empty`, a page the plan does not include is `<x-plan-gate>` told which schedule it is about (`:subdomain`, or its button goes to the public pricing page), a single locked button carries `<x-lock-badge>`, and every tab is the layout's frame (the list tabs kept to a 64rem column until 2026-10, under a header and beside calendar tabs that ran the full width, so the page changed shape as you changed tab). The address under the name is `$role->getGuestUrl(true)`, never `custom_domain` on its own: a direct-mode domain that is pending or failed does not answer, and this is the address people copy. An address with no path is all host, which the kit hides on a phone, so it goes in the path span. The shell and the list pages are server-rendered and wired by plain DOM script, never jQuery or Alpine (Videos and the Plan page's cancel form are Vue islands of their own): the Availability grid sits inside the calendar's Vue mount, so its clicks are heard on `document`; and a label drawn by CSS comes from `attr(data-label)`, never a string in the stylesheet ("Unavailable" was English in twelve languages). A request card says what is asked for AND who is asking, and looks a sub-schedule up as `\App\Models\Group`: a bare `Group` is no class in a view, and was a 500 on the whole tab. User text in a block is wrapped in `<bdi>` rather than given `dir="auto"`, which would move an English title to the left edge of a Hebrew card. The Plan page asks `role/partials/plan-actions` whether there is anything to put in a card before it draws one, so every line of that partial stays behind a condition, and the partial is named in `MarketingPriceTest::AP_PRICE_VIEWS` because the prices moved there. A select inside these lists restates its own padding, because `layouts/app` gives every select `padding: 0.75rem 1rem !important`, which is the room its arrow needs ("Viewe", "Admi"). `role/partials/calendar` and `role/partials/mobile-event-card` are the public calendar and the dashboard's too: a change meant for these pages is gated on `$route == 'admin'`. `ScheduleAdminPagesTest` and `tests/Browser/ScheduleAdminPagesJourneyTest.php` fail the build otherwise.
- **Every page of the admin portal is built from the page kit and opens the same way** - `layouts/app-admin` includes the two kits once (`partials/form-kit-styles`, then `partials/admin-page-styles`), so a page never includes either. Every page is ONE frame: `layouts/app-admin` puts `.ap-frame` on `<main id="main-content">` and on the line under it (80rem of content plus the gutters, in the middle of the room beside the sidebar) and `.ap-frame-bar` on the bar above, so the header, the tabs, what the page shows and the account menu share two edges on every page and at every width. It is on `<main>` itself because `SetupGuide.vue` measures that element to dock beside a form, and the only way out is `<x-app-admin-layout wide>` for a canvas (the seating designer, the box office). Never give a list, a card or a tab a width of its own: until 2026-10 lists and cards kept to a 64rem column against the sidebar while headers and calendars ran the full width, and on a wide window half the portal looked pushed to one side. Three things keep a full frame from looking stretched, all in the kit. A page that is one form with no navigation above it (Add member, Transfer ownership, Scan, Scan agenda, the advanced boost form, Import from Eventbrite, the plan checkout) is `page-shell page-col is-narrow`: one 48rem column in the middle of the frame with its own header INSIDE it (a title at the frame's edge over a centred body is two left edges), and the layout's error list is brought in to that column (`.ap-form-errors:has(~ .page-col.is-narrow)`); a form under the platform admin's navigation is never that, because a narrower column under a navigation that runs the frame has two left edges as well. A form keeps its measure at EVERY width, never from some wide breakpoint: `.page-form-fields`, a `.page-form-actions` row that directly follows them `.page-kv` pairs that are a card's own body, and the whole body of a card set `beside` (so the cards of one page end on one line while they are still stacked) are 48rem at most. The first version capped them only from a 1536px window, but the frame is wider than the old column from 1377px, so a 1440px laptop had inputs 1048px wide and a wider window a narrower form; and it capped every button row and every pair inside a card, including ones whose card was not capped (Merge 480px short of the venues it acts on, two right edges for the numbers of one Queue card), which is why the selectors are that narrow. From a 1440px window (`90rem`, where a card first has room for it) a card set `beside` (`<x-page-card beside>`) puts its title, its line and its `aside` on one side, in a column of 14rem at least, and its body on the other; a page sets it on ALL of its titled cards that are not lists or on none (card by card, one page's content went left, right, full, right, full on its way down; `AdminPortalKitTest` reads every view for this), and a card with no title is never given one so that it can split. A notice's text keeps to 110ch, and a search field grows only in a `form.page-filters`, where a button follows it. A bar that bleeds past the gutters to the edges of the room (the forms' save bar, the appointment editor's toolbar, with `.ap-frame-bleed`) KEEPS its bleed, because that is what covers a card and its shadow scrolling under it; from 102rem, where the frame stops growing, its own border goes clear and its line is drawn at the frame's width by a pseudo-element. A fixed bar measures the frame with `%`, not `100vw`, which counts a classic scrollbar the frame is laid out without. Check any change here at 1280, 1440, 1536 AND a window past 1632px: at 1280 none of this is reached, which is how a column that pushed half the portal to one side went unseen through a whole redesign. A page wraps what it shows in `.page-shell` and opens with `<x-page-header>`: the way back as a link that NAMES the page it leads to (never a "Back" button at the end of the row: that place is for what can be done here), the page's h1, one lead line, and the actions with the forward one last. A platform admin page (`/admin/*`) opens with `admin.partials._navigation` instead, which carries the h1: ONE list (`$adminNav`) feeds its four tabs, the menu a group's tab opens when pointed at (or on ArrowDown), the second row that names every page of the group you are in, and the phone's grouped dropdown, so a page added to one alone is missing for half the visitors (the groups were once menus and nothing else, and nothing on screen said which page you were on: the menu is a shortcut beside the row, never instead of it, and it sits outside the strip, whose sideways scroll would cut it off); after it comes `.page-head` (a lead, with the period select or Export in `.page-actions`), never a heading that repeats the page's name. A section whose pages are separate addresses uses `<x-page-tabs>` (strip and phone dropdown from one list; `strip` keeps the strip on a phone, for three or four short tabs and for a section inside the platform admin, whose own navigation is already a dropdown there). A list is ONE `.page-table`, a table from a tablet up and a stack of rows on a phone: never a second copy of the rows as phone cards, which is how five pages came to draw the same status pill twice. Its headings sort through `<x-page-sort>`, status is the kit's mark (`.event-status`, `<x-sale-status>`), nothing-here-yet is `<x-page-empty>`, a card is `<x-page-card>`, a notice is `<x-page-notice>`, and what the last request said is `<x-page-flash>`, which names the keys it prints, sits where it is seen when the page opens, and leaves those keys on the request so `layouts/app` does not toast the same one over the notice (a key a page does not print is still toasted, and a redirect to a fragment far down a page wants the toast; the note is on the request, never shared with the view factory, which outlives a request inside a test). A notice carries no live-region role unless it answers what somebody just did. What a form got wrong is listed once, by `layouts/app-admin` above every page: a page never prints `$errors->all()` again (the newsletter pages and the platform's Boost page did, so each message was on screen twice). A phone lays a list out as blocks, which drops a table's meaning, so `partials/admin-page-script` says the table roles on every `.page-table`. The same stack puts the head out of sight, so the script sets a small bar above a list whose head can sort or tick every row (Sort by, reverse, Select all); the bar presses the real control in the head, which is why a sortable heading's click handler is delegated on `document`, and it waits until every `DOMContentLoaded` listener has run, because a bar built inside a list that Vue was about to take over became part of that app's template and did nothing. Figures are a `.page-stats is-auto` strip, or `<x-admin-stat-tile>` where a headline number has an icon; pairs are `.page-kv`; a row of filters is `.page-filters`, whose fields the kit sizes itself because `layouts/app` gives every field 1.15rem type and its own padding with `!important` (six filters broke into two rows, and a select's text ran under its arrow). Buttons speak in one voice: brand for the action that goes on, bordered secondary for the rest, red for destroying, `.page-tool` for a list's own tools, `.event-link` inside rows, sentence case (inside `.page-shell` the older capitals buttons are brought into line). Traps met building it: a kit class that sets `display` (`.ap-subtabs`, `.page-tool`, `.page-stats`) is declared after Tailwind's utilities, so `hidden` loses to it (the kit restores the `hidden` attribute inside `.page-shell`; a responsive hide needs a rule beside the class); a `<x-...>` tag inside a comment of a stylesheet partial is compiled, so write the name without the brackets; `overflow-wrap: anywhere` in a table cell lets the column shrink under its own words, so from a tablet up a long word breaks only when it must, and a person's name and address do that only inside `.page-scroll`: in a card that cuts what does not fit, a word that will not break pushed the last column (Remove) out of the card, so a list of people that is not scrolled keeps `anywhere`; and a Tailwind class that is not in the built stylesheet renders as nothing until the next build, so new looks go in the kit as plain CSS. Left as they are on purpose: the first-run new schedule form, the seating box office and designer (compiled Vue tools), the seating report (a print sheet) and the appointment editor. `AdminPortalKitTest` and the `Ap*PagesTest` files fail the build otherwise.
- **Use toggle switches for boolean settings** - In the admin portal, use `<x-toggle>` (or toggle switch markup for Vue pages) for standalone boolean on/off settings. Reserve plain checkboxes for multi-select lists and "required" indicators.
- **Consistent primary action button sizing** - Primary action buttons in the AP should use `px-4 py-3 text-base` sizing to match `<x-brand-link>` / `<x-secondary-link>` components. Do not use smaller `py-2 text-sm` for standalone call-to-action buttons.
- **Keep doc search index up-to-date** - When adding, removing, or renaming doc sections, update `getDocSearchIndex()` in `MarketingController` so the docs search stays accurate
- **Follower emails are visible on all schedule-owner-facing surfaces** - Schedule owners can see their followers' name and email on the followers tab (`show-admin-followers.blade.php`), the newsletter stats and segment-edit pages, and the dashboard recent-activity feed. Follower emails must NEVER appear on public/guest-facing surfaces (public stats, embed widgets, guest pages) - those do not list individual followers. When a user clicks Follow on the guest portal, a consent modal (`resources/views/partials/follow-consent-modal.blade.php`) discloses that the schedule will see their name and email.
- **Every public form needs a honeypot** - Any form a signed-out visitor can submit gets `<x-honeypot />` plus an `App\Utils\HoneypotUtils::isTripped($request)` check in the controller. Match the bail to what the surface renders: guest pages show `session('error')`, so use `back()->withInput()->with('error', __('messages.invalid_request'))`; `x-auth-layout` pages render only per-field errors, so those must `throw ValidationException::withMessages([...])` or the rejection is invisible. For forms whose payload is built by hand in JS, pass `vmodel` and send the value explicitly - the hidden input alone is not submitted. Never add it to a form that posts a genuine `website` value (the schedule edit form, the schedules API), and never to an authenticated-only form, where a password manager could fill it. Turnstile is not a substitute: it is off whenever `TURNSTILE_*` is unset (every selfhost install) and on custom domains.
- **An address that changes something is never a GET** - A page can make a visitor's browser load any address (an image in a description is enough), and on hosted every schedule's page shares the visitor's session with the app. Until 2026-10 opening `/{schedule}/unfollow` took an OWNER off their own schedule and could mark it deleted, and opening `/follow` made the visitor a follower, which shows their name and email to the schedule, with the consent step only in the page. Follow, unfollow, listing an event (`event.curate`), clear-videos and the two calendar disconnects are POSTs with the page's token; the Follow button posts through `window.esPostFollow()` (`partials/follow-consent-modal`), and a follow begun before signing up is finished inside `HomeController::home()`, never by sending the browser to the follow address. `unfollow()` and `bulkUnfollow()` detach a follower and nobody else: an owner or a team member leaves from the Team tab. A route that answers no GET shadows no event address, so a word leaves `Event::SHADOWED_SLUGS` when its route stops answering one (`ShadowedEventSlugTest` holds the list exact). The setup wizard's `/test_database` answers only while the install has no account. `StateChangingAddressTest` holds each.
- **Listing an event is not running it** - Any curator can put a public event on its own list (`EventController::curate()`, or by naming a schedule as a source), and nobody is asked. Until 2026-10 that link carried every right over the event: a stranger's schedule that listed one could delete it, cancel it with a note mailed to its buyers and rewrite it, and a talent or venue schedule that listed one was also given its buyers. So: deleting, cancelling and restoring ask `User::runsEvent()` (whoever made the event, or an owner or admin of the schedule that owns it), on the web through `EventPolicy::delete` and in the API; `User::canEditEvent()` skips a curator that is not the event's own schedule, so a curator edits the events it made and takes any other off its own list (`uncurate`); and only a curator-type schedule can list somebody else's event, because a venue or a performer is put on an event by the event's own people, in its form. The venue an event is at still edits it and does not delete it. An event from before `events.creator_role_id` belongs to the schedule it was first put on (`owningScheduleId()`), or its owner would be locked out. A new action on an event picks one of the two rules deliberately: `canEditEvent()` reaches the venue and the performers, `runsEvent()` does not. A schedule that DECLINED an event (the link stays, at `is_accepted = 0`) has neither edit rights nor its buyers. And what a schedule does to "all its events" at once goes through `Role::ownEvents()`, never `events()`, which is every link: the timezone fix, Update all slugs and Update all categories used to rewrite events a curator merely lists. `EventListingRightsTest` holds each.
- **A link in a mail proves something only where the server checks what only the server could have written** - The email verification links (an account's and a schedule's) were built signed and never checked: the route asked for the account's own id and a hash of its own address, so an account verified any address it had typed and a stranger verified any schedule's, and verifying is what hands over the schedules and tickets waiting for that address (`claimRolesByEmail()`, `claimSalesByEmail()`). Both requests check the signature now, by PATH (`hasValidRelativeSignature()`), because the request that checks a link can see another scheme or host than the one that mailed it; the whole-address form is still accepted for mail already sent. A newsletter's click link is signed for its own address (`/nl/c/{token}/{signature}/{address}`, `NewsletterService::clickSignature()`): unsigned, it went wherever its last segment said for anybody holding any recipient's token. An unsigned link from older mail is followed only to this install's own pages or an address written in that newsletter, and otherwise to the sending schedule's page. `VerificationLinkTest` and `NewsletterClickLinkTest` hold each.
- **Nothing rewrites sanitized HTML inside a tag, and a description cannot carry an id a page mounts** - A description is CommonMark with raw HTML allowed, then HTMLPurifier, which is the boundary. `UrlUtils::convertUrlsToLinks()` runs AFTER it, at print time, and used to be one regex over the whole string: an address inside an image's alt text was wrapped in a link there, the link's quotes ended the attribute, and the rest of the tag was markup nobody had sanitized. It walks the HTML tag by tag and links text only; anything else that post-processes sanitized HTML must do the same. Separately, a description may carry `id` attributes (heading anchors), and Vue mounts by id selector on the first match in the page: `MarkdownUtils::RESERVED_IDS` lists every id a page mounts (`MarkdownReservedIdsTest` reads the views and fails when a `.mount('#x')` is added and not listed), and the apps on pages that print a description start only on an element carrying `data-vue-root` (`.mount('#x[data-vue-root]')`), which the sanitizer strips, so a description stored before the list grew cannot stand in for the page's own element.
- **"Nobody runs this schedule" is `! hasRealOwner()`, never `! isClaimed()`** - `isClaimed()` also asks for a verified contact, and on hosted every change of email clears that stamp, so an OWNED schedule read as a placeholder: anybody following it could edit it and merge it away. `Role::isEditableBy()` and `canMergeRoles()` ask both. The curator's "merge duplicate venues" proves only that the venues share a name and a country on one of the curator's coming events, and `performMerge()` carries a source's TEAM to the target at each person's level: a caller who merged their own unverified venue into somebody else's claimed one ended as an owner of it. So a source with a team is merged only onto a venue the caller runs, a source somebody runs only by its own people, and a venue its owner deleted is not revived by somebody else's tidying. `ScheduleMergeGuardsTest` holds each.
- **`Role::users()` counts followers, so it is never a permission check** - three calendar sync gates asked it: Outlook's schedule sync (it changes the sync direction and pulls with the CALLER's token) and "send this event to my calendar" on both providers, which copied a draft, an unlisted or password event or a booking to anybody following a schedule the event is on. Schedule sync is the owner's on both providers; one event to my calendar and a member's standing sync ask `isMember()` (owner, admin, viewer). `CalendarSyncMembersOnlyTest` holds each, with the services mocked.
- **Every door that signs a person in with a password asks for two-factor, and wrong codes count against the account** - the submit-your-event form signs a person in beside the event, and checked the password, the lockout and the block but never `hasTwoFactorEnabled()`: the password alone gave a full session. It refuses such an account and sends it to the login page. The challenge counts wrong codes per ACCOUNT (five, then five minutes), because the route's limit is per address and signing in again renewed the window. `TwoFactorDoorsTest` holds each.
- **A place on a team gives what its level says, and leaves nothing behind** - A VIEWER reads the schedule's calendar, marks their own days and scans tickets. Newsletters (`NewsletterController::getRole()`, which used to refuse followers only: a viewer could write, send, import ten thousand addresses and read every recipient list) and every admin tab but `schedule` and `availability` are for owners and admins, asked in `RoleController::viewAdmin()` and not only in the tab strip. Whoever LEAVES a team, or is made a viewer, keeps no rights over the events they made there: "I made it" (`events.user_id`) counts only while the person still runs the event's schedule (`User::madeAndStillRuns()`), and `ScheduleTransferService::handOverEventsOf()` passes those events to the owner, except one that has already taken money, because a refund looks up the maker's payment account. On hosted a team belongs to the Enterprise plan, and `User::isEditor()` asks the plan (`planAllowsTeamAccess()`), as do the three event rules: the tab page used to turn a team admin away after a downgrade while every save behind `isEditor()` still went through. A feed on a plan without feeds can still be dealt with by its owner (`ApFeedPageTest`). `TeamRolesAndLeaversTest` holds each.
- **A waiting account is not handed to whoever types its address** - an invited team member, or somebody who bought a ticket, has an account with no password yet. On hosted, sign-up proves the address with a code. Off hosted there is no code step, and sign-up used to set the password on the spot: anybody who knew an invited address took its team level. Off hosted the sign-up page, the API's register and the submit page's account (`EventController::createAccountWithCode()`) mail that address its set-password link (`StubAccountUtils::send()`) and adopt nothing; the booking form's selfhost path (`createAndLoginUser()`) refuses any address that has an account. `TeamRolesAndLeaversTest` and `GuestSubmitProtectionTest` hold each.
- **A saved value never goes inside a script expression on a page other people open** - the schedule's settings page (`role/edit`) is opened by every editor and the owner. `custom_domain` was printed inside an Alpine `x-data` expression, and Alpine is started on every page and evaluates that attribute after the browser has undone Blade's escaping; four style columns were echoed into single-quoted strings in the page's script, where a trailing backslash ends the string. A value rides in a `data-*` attribute read with `$el.dataset`, or is written with `@json` from a BARE variable. `custom_domain` must parse to a plain host (`RoleUpdateRequest`), and `store()` does not fill it. `StaffFacingMarkupTest` holds each, with the seating report's CSV cells (`CsvUtils::sanitizeCell()`).
- **The date a sale is for is decided by the event, never by the request** - stock, the house limit and the RSVP limit are kept per date string. Checkout and RSVP used to take `event_date` as posted, so every unused date had a full allotment: a sold-out ticket type sold again under another date, outside the real night's counts. `TicketController::assertSaleDateIsTheEvents()` applies the test the event page applies to the date in its own address (`Event::isOccurrenceDate()` and `matchesDate()` in the schedule's timezone), as the attendee import and the box office already did; a request with no date is given the event's own. A new door that creates a sale asks it too. `SaleDateBelongsToTheEventTest` holds it.
- **A stored mail password belongs to the server it was typed for** - Every member who may edit a schedule can post its form and its Test email button. A save or a test that names another host, port, username or encryption while the password is the masked placeholder, or is left out, is refused (`RoleController::mailServerMovedWithoutPassword()`): it used to send the stored password wherever the new values pointed. The check sits at the top of `update()`, before the calendar blocks that save as they go. `ScheduleMailSettingsTest` holds it.
- **An account is shut out only by `AccountBlockService`, and a new one is refused only through `Blocklist`** - `/admin/blocked`. `users.blocked_at` is read on every request by `EnsureAccountNotBlocked`, before the request AND after it: some twelve places sign a person in (a password, Google, a two-factor code, a set-password link, a claim link, the guest forms), and whatever does it during a request, a blocked account does not leave signed in. A door that DOES something in the same request as the sign-in (the guest submit form) says no itself first, and the API has no session and asks in `ApiAuthentication`. A block takes down the schedules the account owns through `ScheduleDeletionService::markDeleted()` and records which in `account_blocks.role_ids` (not on `roles`, which is at its row limit), because Unblock brings back those and no others; it also removes only the list entries the block added, and one that another blocked account stands behind passes to that block. The list (`blocklist_entries`: an email address, an email domain, a network address or range) is asked where an account a person can sign in to is MADE (`NotBlocklisted` on the sign-up form and its code step, the API's register and the guest forms; `Blocklist::refusal()` in the social callback) and never at sign-in, so an entry never touches an account that exists; it is not asked at checkout or on a newsletter form. The address a visitor comes from is `Blocklist::address()`, never `$request->ip()` or `audit_logs.ip_address`: behind Cloudflare the socket's peer can be an edge address thousands of visitors share, and refusing one refuses them all. It is recorded at sign-up in `users.signup_ip` and kept 90 days (`PrunePersonalData::SIGNUP_IP_DAYS`, which the privacy policy's retention table states) or for as long as the account is blocked. An address is compared in its canonical form (a `+tag`, and Gmail's dots, do not make a new one), a range may not be wider than /16 or /32, and a lookup that cannot be made refuses nobody. `markDeleted()` also returns a newsletter that was waiting to be sent to a draft: `NewsletterService::send()` does not ask whether the schedule is still up. The operator's overview pages (`AdminDashboard`, `RealtimeDashboard`, `RealtimeActivity`) leave a blocked account out through `BlockedAccounts`: the account, every schedule it owns, and every event it made or those schedules own, because an event it also put on somebody else's venue outlives the takedown; a new card or count there asks it too (Insights, which is history, does not). `AccountBlockTest` and `BlockedAccountsHiddenTest` hold each.
- **An account that may be spam is found only by `SpamWatch`, and is never acted on by it** - `/admin/blocked#suspects` and the `spam_suspects` row of `AdminAlertService` list an account with one of the operator's watched words (`Setting` `spam_watch_words`; never saved, `DEFAULT_WORDS`; saved empty, off) in a live schedule it owns (name or subdomain) or an event it made, or in its own name or email ONLY once it owns a live schedule (`owners()`): ticket buyers and followers have accounts too, and a buyer called "PayPal Buyer" publishes nothing. A match is a reason to look: the operator blocks the account or marks it not spam (`spam_clearances`, for good, because a bitcoin meetup would otherwise be back with every new event; Unblock marks it too). Which accounts match is decided by the database's `LIKE`, which ignores case and accents, and the page lists what the same SQL finds (`matches()`); PHP only picks the word to mark (`wordIn()`). A default word must not sit inside ordinary names (`zelle` was in Gazelle). The scan is three full table scans and the alert row is counted on every admin page, so the matched ids are cached with `Cache::flexible()` under a key that carries the words, while blocked, admin, demo and cleared are asked live (`canBeFlagged()` is the same rule in PHP, for the account page's card): a decision drains the badge at once, and the badge and the list read one query. `spam_clearances` is in `PersonalDataExportService::NOT_EXPORTED`. `SpamWatchTest` holds each.
- **A blog post is written by `BlogWriter`, published only past `BlogGate`, and says about the product only what `config/blog_facts.php` holds** - The blog (`blog.{domain}`, nexus only, routes named `blog.*`, never `marketing.*`) is written unattended: both generators (`app:generate-daily-blog-post`, `app:generate-sub-audience-blog`) and the admin's Generate button go through `App\Services\Blog\BlogWriter`, three model calls (a brief, a draft, an edit) and then the check in code. Until 2026-10 it was one prompt that knew one fact about the product: every one of the twenty newest posts had exactly two links, fourteen called it "a platform like Event Schedule", and several described features that do not exist. Four things are load-bearing. The facts (`config/blog_facts.php`: plan by plan, with a "does not do" list, prices filled from `PlatformPricing`) and the user guide's own sections (`BlogGuide`) go with every post: given one-line facts alone the model invented screens and buttons, so a feature that moves plan in `docs/FEATURES.md` moves there in the same change. A rule that matters is in `BlogGate`, not only in a prompt: told to copy addresses exactly, the model still wrote a host that does not exist (a link off `BlogLinks::targets()` is unwrapped), and "never use em dashes" left them in four of twenty posts. What fails is HELD as a draft with `held_reason` (the admin list, and the `blog_posts_held` row of `AdminAlertService`), never thrown away and never published. And the writer runs inline, never as a queued job: the queue is drained inside the scheduler's own process and a post takes a minute and a half, so the admin's button runs the three calls as three requests (`begin`, `draft`, `finish`). No example titles in a prompt (they are copied: "5 Ways to Boost" became eight titles starting "Boost"), and a new subject for the daily post goes in `config/blog.php` (`directions`; `subjects` marks one full). On the pages: a post's first paragraph comes before its picture (`BlogPost::openingAndRest()`); a class name built at run time never reaches the stylesheet (the audience card was white text on white for 21 of 38 audiences), and `prose` does nothing here because the typography plugin is not installed (the article's styles are `blog/partials/styles`); a post has one section (`BlogPost::CATEGORIES`), which is a page of its own from `SECTION_MIN_POSTS` posts; a merged post (`redirect_slug`) answers with a 301 and is outside `scopePublished()`; and a view is counted only for a request that passes `PageView::isBot()` and `isSuspiciousRequest()`. The review of the existing posts (`BlogReview`, `/admin/blog-review`) proposes and changes nothing. `BlogWriterTest`, `BlogFactsTest`, `BlogPagesTest` and `BlogSeoTest` hold each.
- **Never expose raw exception messages to users** - In catch blocks that handle user-facing responses, catch `QueryException` (and other system exceptions) separately and show a generic error message. Use `report($e)` to send to Sentry. Only show `$e->getMessage()` for intentional business logic exceptions.
- **Always translate new language keys** - When adding a key to non-English `resources/lang/` files, use proper translations (check existing similar keys in the file for reference), never copy the English string
- **Use bordered panels for AP warnings** - Never use plain colored text for warnings. Use `bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-3` with a warning triangle SVG icon (`w-5 h-5 text-amber-600 dark:text-amber-400`) inside a flex layout
- **Never modify WP pricing feature lists or header/footer links** - The feature lists on the /pricing page and the WP header/footer navigation links are manually curated. Do not add, remove, or reorder items unless explicitly asked.
- **Build every email on `<x-email.*>`** - Every HTML email view builds a theme first, `@php($theme = \App\Utils\EmailTheme::guest($role))` (or `owner($role)` / `account()`), and renders inside `<x-email.layout :theme="$theme">` with the components in `resources/views/components/email/`. That is where the dark mode, RTL, preheader, Outlook fixes and AA-checked colours live, so a hand-rolled shell loses all of them. The three voices: **guest** is mail a schedule sends its own audience (tickets, appointments, announcements), in its logo, name and accent, and never naming the platform (`docs/BRANDING_MATRIX.md` rule 5); **owner** is the platform writing to a schedule's owner about it, with the schedule's identity in platform colours; **account** is the platform writing to a person, under the app-name wordmark. Pick the voice explicitly, never from whether a `$role` exists. Bind dynamic values as `:prop="$x"`, never `prop="{{ $x }}"`: a component escapes the value again and every `&` in a signed URL becomes `&amp;`. Show a state with the heading's `tone` and a callout, never a coloured header or button. Use `{{-- --}}`, never `<!-- -->`: an HTML comment is sent to the recipient and Blade still runs inside it (a "hidden" QR block attached its image to every ticket email that way). Laravel's Markdown mail (`vendor/mail/html/*` plus `themes/default.css`, which Laravel inlines) mirrors the account voice by hand. `EmailComponentUsageTest`, `EmailLayoutContractTest` and `EmailThemeTest` fail the build on drift.
- **A newsletter takes every colour from `NewsletterTheme`, and its events from the list the schedule's own page shows** - The newsletter is the one outgoing mail whose colours are the OWNER's, so it is not built on `<x-email.*>`: the design (`modern`, `classic`, `minimal`, `bold`, `compact`), the three saved colours, the typeface and the corners go into `App\Utils\NewsletterTheme::make()`, which derives the ground, the panels, the hairlines, the quieter inks, the label on a button and an accent that reads as text, as solid 6-digit hex. Every text and surface pair holds 4.5:1 for ANY three colours, a mid-grey or a pure red page included: a panel or a tint is pulled back toward the page until the black or white that reads on the page still reads on it (`surface()`), and an ink is moved along its own hue in whichever direction gets there (`readable()`). `NewsletterThemeTest` sweeps a grid of palettes; before it, ten pairs fell as low as 2.92. A block (`emails/newsletter_blocks/_*`) and an `<x-newsletter.*>` component read `$nl`; none mixes a colour of its own or appends an alpha byte (Outlook drops an 8-digit hex and the text turns black), a rule or bar drawn in the accent uses `accentMark` (an accent equal to the page is invisible), and the one literal is the white tile sponsor logos stand on. A block branches on the design only where the designs truly differ: until 2026-10 every block branched five ways on `$template` and Bold hard-coded its own navy on two of them. Events are resolved into plain rows by `NewsletterService::eventRow()` once per batch of recipients (the view is rendered per recipient), from `EventRepo::upcomingForGuest()` with a year's horizon for a series; hand-picked ones pass the same gates in `pickedEvents()`. `$role->events()` has no accepted filter and `starts_at >= now` drops a recurring series the day after it begins, which is how an unanswered booking request was mailed to a venue's audience and a weekly night was never listed. Each row is the occurrence a reader can still go to (`aheadOfNow()`: a series is dated by day, so a 9am class mailed at 2pm was listed under this morning), an event that has ended is left out even when hand-picked (a clone carries ticked ids for months), a price is printed only where the event page prints one (`! rsvp_enabled && canSellTickets($date)`), and the builder's picker reads the same list (`eventChoices()`), never a query of its own. Rows are resolved inside `inScheduleLocale()`, because the job runs in the dispatcher's locale and a row carries month names and "Sold Out"; the clock is `format()`, not `translatedFormat()`, as on the event page. `prose()` styles owner-written HTML in ONE pass over its tags and never with a pattern that walks the text: on a long block `preg_replace()` answers null, the return type turns that into an Error, and the send job catches `\Exception` only. So every element keeps its gap and the Text block's cell is `gap - para`. The shared pieces are components and not includes, because an include inherits its parent's `$align` and `$size`. A block saved before a setting existed has no key for it: read it with `??` first, then validate. An alignment is said as an attribute AND as both margins for every side, or "left" lands wherever the mail's direction starts. A logo with no recorded size (most uploads before 2026-09) is given a box and no width or height, or it is squashed to the ratio that was only assumed. The preview text lives in `style_settings`, which travels to the next newsletter and to saved templates, so those copies go through `Newsletter::designSettings()` (a clone and an A/B variant are the same mail again and keep it), and it is never `strip_tags()`ed, which cut "We <3 Fridays" to "We": it is printed escaped. A design nobody changed from a preset of before 2026-10 arrives as today's when the next newsletter starts from it (`movedToCurrentPreset()`, which does not compare the events layout, because choosing a preset has always kept it). A picture keeps its own shape (mail cannot crop), a clock time is wrapped `dir="ltr"`, letter-spacing goes through `$nl->tracking()` and italics through `$nl->italic()`, both nothing in a right-to-left mail, and anything long (a heading, an event's name, a code block) wraps. The builder lays the preview out at a real width (680px or 390px) and scales it to its column, because the column is narrower than the mail's 620px breakpoint on most laptops. `NewsletterDesignTest` and `NewsletterThemeTest` hold each.
- **Never use a colored side-stripe on cards or callouts** - No `border-left: 4px solid ...` (email HTML), `border-l-4` / `border-s-4` (Tailwind) or RTL-flipped equivalent as an accent on one side of a card, panel or alert box: it reads as AI-generated. Use a plain card (`<x-email.panel>` in an email, `ap-card` in the AP), or for a state callout a tinted background with a 1px tinted full border (`<x-email.callout tone="...">` in an email; amber `#fffbeb`/`#fcd34d`, red `#fef2f2`/`#fca5a5`, green `#f0fdf4`/`#86efac`). Nav active indicators, CSS triangles and owner-selectable newsletter template designs are exempt. `tests/Unit/EmailSideStripeTest.php` fails the build if one returns to an email view. Emails are swept; the known AP leftover still to fix is `admin/revenue.blade.php` (four warning panels) - do not copy it.
- **Never add decorative line drawings to the WP** - Do not add outline SVG illustrations of objects or scenes (building facades, floor plans, mic stands, trapeze rigs, velvet ropes, bunting, rows of repeating outline glyphs) as ornament behind headlines or inside panels on the marketing site. WP depth comes from gradient auroras, grid overlays, noise texture and light beams. Functional icons, brand logos, product screenshots, `<pattern>` textures and abstract strokes (route lines, connector curves, hand-drawn heading underlines) are fine.
- **Use Flatpickr for date inputs** - Always use Flatpickr (already bundled via `app.js`) instead of native `type="date"` inputs. Use `dateFormat: "Y-m-d"` with `altInput: true` and `altFormat: "M j, Y"` for a human-readable display.
- **Use Vue.js, not Alpine.js or jQuery** - Always use Vue.js for JavaScript interactivity. Do not use Alpine.js directives (x-data, x-show, @click, etc.) or jQuery. When modifying files that use Alpine.js or jQuery, migrate the relevant code to Vue.js.
- **Guard user data inside Vue mounts (template injection)** - The app uses Vue's full build with the runtime template compiler, so any element Vue mounts has its server-rendered HTML compiled as a Vue template. User-controlled data echoed server-side as a text node inside a Vue-mounted element MUST carry `v-pre` (or use the `<x-user-text>` component). The content of a `<textarea>` and the label of an `<option>` are text nodes too, and Vue compiles both: the event form's description, its category and seating plan options and the agenda part names beside fan content were live for that reason until 2026-10 (`EventFormTemplateInjectionTest`). So was the ticket currency in the Payment row's notice, a free string until the form requests began refusing one the pickers do not offer; `<x-input-error>` carries `v-pre` on every message for the same reason. Blade's `{{ }}` HTML-escaping does NOT stop Vue from compiling a Vue mustache expression in the value, so an unguarded value runs as JavaScript (CSP `unsafe-eval` is intentionally on and will not block it). Passing data via `@json()`/props and rendering with Vue's own `@{{ }}` interpolation is safe, and mustaches in HTML attribute values are not compiled.
- **`@json` is compiled by `App\Support\BladeJson`, not by Laravel** - Laravel's directive splits its argument on EVERY comma, so `@json($role->groups->map(fn ($g) => ['id' => $g->id, 'name' => $g->name]))` compiled to `json_encode(..., 512)`: the value survived and the default `JSON_HEX_TAG` escaping was gone. Inside a script block `<!--<script` in a string then kept the block's own closing tag from ending it, the script ran on into the markup and failed to parse, and nothing on the page started: one event with such a name stopped the event form for every event of its schedule, and some two hundred calls had the shape. `AppServiceProvider` registers `@json` over Laravel's: it splits only on the commas PHP reads as argument separators, and ORs `JSON_HEX_TAG` into any flags a caller names. Compiled views outlive a change to a directive (selfhost updates run `view:clear`, a hosted deploy builds a new container; locally run it yourself). `BladeJsonDirectiveTest` compiles every view that uses it, and `EventFormTemplateInjectionTest::test_a_name_cannot_swallow_the_script_it_is_printed_in` holds the event form.
- **`@context` is registered over Laravel's own directive, so a JSON-LD key is printed as written** - Laravel 12 added a `@context` Blade directive (Laravel's Context), and every `"@context": "https://schema.org"` in the views compiled as an `if` with no end: the page did not compile, or, inside `{!! !!}`, printed PHP source into its structured data. `AppServiceProvider` registers `@context` to print the word, beside the `@json` override. Never remove it to use Laravel's Context in a view; write `{{ context()->get(...) }}` instead. `BladeViewsCompileTest` compiles every view and fails the build otherwise.
- **Keep template variable lists in sync** - The same template variables are documented in both `event-graphics.blade.php` and `creating-schedules.blade.php` (integrations Advanced section). When adding or removing variables in `EventTextGenerator::parseTemplate()`, update both doc pages.
- **Use button components in the AP** - Use `<x-brand-button>` for primary actions (save, submit, filter), `<x-secondary-link>` for secondary navigation (back, cancel, clear), and `<x-danger-button>` for destructive actions. `<x-secondary-button>` is only for small utility buttons within forms (validate, view map, edit slug); for standalone action buttons use `<x-secondary-link>` or a plain `<button>` with `<x-secondary-link>` classes (`px-4 py-3 text-base`). Small utility buttons (e.g. "Add 30 days") may use inline styles but must use `focus:ring-[var(--brand-blue)]` for focus rings. The three tabbed forms (the event form, the schedule form, the settings pages in `resources/views/profile/`) all use `<x-brand-button>` for Save and `<x-brand-button size="sm">` for a row's own action: the settings pages used the black `<x-primary-button>` until the 2026-10 redesign brought them into line. Never use inline button styles when a Blade button component exists.
## Terminology
- **WP** - Marketing site (from WordPress acronym)
- **AP** - Admin portal
- **GP** - Guest portal / Client portal
- **Role** (code) = **schedule** (UI) - The `Role` model represents a schedule. Always refer to it as "schedule" in text
- **Group** (code) = **sub-schedule** (UI) - The `Group` model represents a sub-schedule. Always refer to it as "sub-schedule" in text
- **Schedule types** - Only 3 types exist: Talent, Venue, Curator. Never reference "vendor" as a schedule type.
## Brand Colors
- **WP primary blue:** `#4E81FA`
- **WP gradient:** `#4E81FA` -> `#0EA5E9` -> `#22D3EE`
- Shared `.text-gradient` class is defined in `resources/css/marketing.css`
- Never use purple/violet/indigo/fuchsia/pink as WP brand colors
- Icon accent colors (on sub-audience-cards) are decorative and exempt
- **AP brand blue via CSS variables** - In AP/auth views, use CSS variables instead of hardcoded hex: `var(--brand-blue)` (primary), `var(--brand-blue-light)` (lighter), `var(--brand-blue-dark)` (hover/darker). These auto-adapt between light and dark mode. For **text/borders**: `text-[var(--brand-blue)]`, `border-[var(--brand-blue)]`. For **button/element backgrounds**: `bg-[var(--brand-button-bg)]`, `hover:bg-[var(--brand-button-bg-hover)]`. For **button gradients**: `from-[var(--brand-button-bg-light)]`, `to-[var(--brand-button-bg)]`, `hover:from-[var(--brand-button-bg)]`, `hover:to-[var(--brand-button-bg-hover)]`. In inline styles: `color: var(--brand-blue)`, `background-color: var(--brand-button-bg)`. In JS (e.g. Chart.js canvas): `getComputedStyle(document.documentElement).getPropertyValue('--brand-blue').trim()`. The split exists because dark mode needs brighter blue for text readability but darker blue for button backgrounds (white text contrast). Do NOT add `dark:` variants for brand blue classes - the CSS variable handles dark mode automatically.
- **Never hardcode a surface or ink hex - use the `--ap-*` tokens.** The AP supports six palettes (light: Sand/Mist/Paper, dark: Espresso/Midnight/Carbon), ported from the Flutter client's `InTheme` design system. A literal hex cannot follow the active palette. Use `rgb(var(--ap-surface))`, `rgb(var(--ap-bg))`, `rgb(var(--ap-border))`, `rgb(var(--ap-border-strong))`, `rgb(var(--ap-ink))` / `-ink-2` / `-ink-3` / `-ink-4`, the sidebar's `--ap-rail*` family, `--ap-surface-hover` / `-active`, and the composites `--ap-shadow-*`, `--ap-hairline`, `--ap-tint-1` / `-2`. In Tailwind markup just use the palette classes (`bg-gray-800`, `dark:text-gray-300`, ...) - the whole ramp resolves through those tokens, so it themes automatically. All six palettes are defined in `resources/css/app.css`.
- **The `--ap-*` fallback block is duplicated in `marketing-app.css`** - it is a separate Vite entry that emits its own copy of the Tailwind utilities from the same config. If you add or rename a token, mirror the `:root` / `.dark` fallback there or every `bg-gray-*` on the marketing site resolves to an undefined variable. Marketing, docs and the guest portal deliberately do NOT get the six variants: they set no `data-theme`, so they land on the fallback and render exactly as they always have.
- **Theme variants are opt-in per layout, not per shell.** `layouts/app.blade.php` is the shell for BOTH the admin portal and the guest portal (`app-guest.blade.php` opens with `<x-app-layout>` just like `app-admin.blade.php`). Only `app-admin.blade.php` passes `:theme-variants="true"`, and `layouts/auth.blade.php` opts in directly. Never move that flag into the shell.
- **Do not let `@tailwindcss/forms` own the `<select>` chevron.** The plugin inlines `colors.gray.500` into an SVG *data URI*, which cannot resolve a page-level `var()`, so the arrow vanishes once the ramp is variable-driven. `app.css` re-declares that `background-image` with a literal stroke per palette - keep it in sync if a palette's gray-500 changes.
- **Custom 400 shade overrides** - `tailwind.config.js` overrides the 400 shade for green, red, amber, blue, indigo, and purple to be slightly brighter (better contrast on dark backgrounds). These are used with `dark:` prefixes (e.g. `dark:text-green-400`). No template changes needed - Tailwind uses them automatically.
## AP Design System
The AP uses a refined dark/light design language. Follow these principles when building or modifying AP components:
- **Depth through shades, not borders** - In dark mode, create visual hierarchy using subtle background shade variations (e.g. `#1A1A1A` → `#252526` → `#2d2d30`) and subtle gradients. Avoid relying on bright borders or heavy drop shadows for depth.
- **Inset shadows for active/selected states** - Active or selected items in segmented controls, tabs, and toolbars should use an inset shadow (e.g. `box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.5)`) with a slightly different background shade to create a "pressed" feel.
- **Ultra-subtle separators** - Dividers between items in grouped controls should be barely visible: use `w-px` width with very low opacity (e.g. `bg-white/[0.08]` in dark mode, `bg-black/[0.08]` in light mode).
- **Generous rounded corners** - Grouped controls and containers use large border radii (`rounded-xl` to `rounded-2xl`). Individual items within groups use slightly smaller radii (e.g. `rounded-lg` to `rounded-xl`).
- **Subtle shadows** - Use `shadow-sm` for resting states and `shadow-md`/`shadow-lg` on hover. Never use heavy or colored shadows. Dark mode focus ring offset: `dark:focus:ring-offset-gray-800`.
- **Outline-style icons** - Prefer thin stroke/outline icons (not filled) in the AP. Use consistent sizing (`h-5 w-5` or `h-6 w-6`).
- **Smooth transitions** - All interactive elements should use `transition-all duration-200`. Hover effects can include subtle scale (`hover:scale-105`) and shadow changes.
- **Use `ap-card` for AP panels and cards** - All card/panel containers in the AP should use the `ap-card` class with `rounded-xl`. Do not manually add `bg-white`, `shadow-sm`, or `border border-gray-200` - `ap-card` handles light/dark backgrounds, shadows, and the top-edge glow automatically. Use `bg-gray-50`/`bg-gray-100` (`dark:bg-[#252526]`/`dark:bg-[#2d2d30]`) for secondary surfaces and hover states.
- **Consistent panel spacing** - Use `gap-4` for grid gaps between panels/cards and `space-y-4` for vertical spacing between page sections. Do not use `gap-6` or `space-y-6` for panel layouts.
- **Dashboard-style stat panels** - A stat card with an icon is one of two components, never hand-rolled: `<x-dashboard-tile>` on `/dashboard` (a link to the page behind the number: figure and caption, a strip of bars, one label and value under a hairline) and `<x-admin-stat-tile>` on the platform admin's pages (`/admin/users`, `/admin/revenue`, `/admin/growth`). Both are an `ap-card rounded-xl` with the `dashboard-icon` class (`p-2 rounded-xl`, a subtle `bg-{color}-50 dark:bg-{color}-500/10` background, a `--icon-glow` CSS variable, a `w-5 h-5` icon) and a `dashboard-stat-value text-3xl font-bold` figure; a new stat card anywhere else in the AP follows the same anatomy. Never override `.ap-card` CSS-driven polish (top-edge gradient glow, inset shadows, icon halos, text shadows) with inline styles. Never use `rounded-full` circles or saturated `bg-{color}-100 dark:bg-{color}-900` backgrounds for panel icons. A partial that a tile or a list includes must not test a flag by a name its parent might also hold (`$live`, `$small`): `@include` hands down every variable of the including view, which is how the first refresh of the Realtime tile redrew the other three tiles' bars (`home/_bars`, `home/_thumb`).
## Build & Development Commands
```bash
# Install dependencies
composer install
npm install
# Build frontend assets
npm run dev # Development with hot reload
npm run build # Production build
# Run development server
php artisan serve
# Database
php artisan migrate
php artisan storage:link
```
## Testing
PHPUnit (Feature/Unit) tests are safe to run locally with `php artisan test`, and safe to run in several sessions at once.
`tests/bootstrap.php` runs before Laravel boots and points each run at its own schema:
- **Per session.** `eventschedule_test_<token>`, where the token is the first 8 alphanumeric characters of `CLAUDE_CODE_SESSION_ID`. Set `TEST_DB_TOKEN` to choose one yourself (non-alphanumerics are stripped and it is capped at 16 characters, so `my-feature-one` becomes `myfeatureone`).
- **No token set** (CI, or a plain terminal): the shared `eventschedule_test`, exactly as before. `env -u CLAUDE_CODE_SESSION_ID php artisan test` forces this, which is the escape hatch on a machine where the grant below is not available.
- **Same schema twice at once**: the second run waits on a lock instead of dropping the first run's tables mid-transaction, which used to hang the suite on a `lock_wait_timeout` measured in years.
- **Housekeeping.** Schemas unused for 3 days are dropped on the next run; override with `TEST_DB_PRUNE_DAYS`.
Creating those schemas needs a one-time grant, already applied on this machine. On a new machine, as MySQL root:
```sql
GRANT ALL PRIVILEGES ON `eventschedule\_test\_%`.* TO 'eventschedule'@'localhost';
FLUSH PRIVILEGES;
```
The `\_` escapes are load-bearing: they make the underscores literal, so the pattern can only ever match `eventschedule_test_<something>`.
**Dusk browser tests wipe the database `.env.dusk.local` points at** - which is now
`eventschedule_test_dusk`, its own schema, matched by the same `eventschedule\_test\_%` grant the
PHPUnit schemas use. It used to point at `eventschedule`, the dev database, which is why running
Dusk locally was forbidden.
Dusk needs the app served at the `APP_URL` in that file, so start `php artisan serve` (port 8000)
first. It is otherwise unaffected by the per-session PHPUnit schemas above.
```bash
# Run Feature & Unit tests (safe locally)
php artisan test
# Run all browser tests (CI only - see warning above)
php artisan dusk
# Run specific test file
php artisan dusk tests/Browser/GeneralTest.php
# Test setup (first time only)
php artisan dusk:install
php artisan dusk:chrome-driver
cp .env .env.dusk.local
```
Test files: `tests/Browser/GeneralTest.php`, `TicketTest.php`, `CuratorEventTest.php`, `ApiTest.php`, `GroupsTest.php`
## Code Quality
```bash
# PHP code style (Laravel Pint)
./vendor/bin/pint
# Check for security vulnerabilities
composer audit
```
## Release Notes
When the user asks for "release notes" (or "releasenotes"), generate the notes for the **next**
version of the app and print the markdown in chat. Do not create a GitHub release/tag and do not
bump version files unless explicitly asked separately. Follow the established style at
https://github.com/eventschedule/eventschedule/releases.
**Steps:**
1. **Find the last release and next version.** The authoritative last published release is
`gh release view --json tagName,name` (cross-check `version_installed` in
`config/self-update.php`). Versions are `vMAJOR.MINOR.PATCH` (e.g. `v1.0.111`). The next
version is a **patch bump** by default (`v1.0.111` -> `v1.0.112`); only use a minor/major
bump if the user asks.
2. **Review changes since the last release.** Run `git log <last-release-tag>..HEAD --oneline`.
If the tag is missing locally (local tags can lag GitHub), run `git fetch --tags` first. Read
the actual commits closely enough to describe each change accurately; for a referenced issue/PR
you can read it with `gh issue view <n>` / `gh pr view <n>` for a clearer summary.
3. **Write short, user-facing bullets** matching the house style:
- Bullet list only, each prefixed with `Added:`, `Updated:`, or `Fixed:`. No emoji.
- Keep it short and sweet (past releases are ~1-7 bullets). Describe user-facing impact,
not implementation details.
- Skip internal-only commits (test-only changes, version bumps, CI, no-op refactors).
- Merge related commits into a single bullet.
- When a commit references an issue/PR number (e.g. `#89`), link it inline:
`[#89](https://github.com/eventschedule/eventschedule/issues/89)`.
4. **Link features to the user guide (not fixes).** Every `Added:` and `Updated:` bullet must
include a user-guide link - the user guide is the public docs at
`https://eventschedule.com/docs/{slug}#{anchor}`. Do NOT add user-guide links to `Fixed:`
bullets: the docs describe features, not bug fixes, so a fix has no matching section. (Inline
issue/PR links like `[#90](...)` are still fine on fixes.) Link each feature to its relevant
section (e.g. a trailing `[Learn more](...)` or by hyperlinking the feature name): find the page
slug from the `marketing.docs.*` routes in `routes/web.php`, and the section anchor from
`MarketingController::getDocSearchIndex()` or the feature->anchor map in `app/Utils/HelpUtils.php`.
Take slugs/anchors from those sources - never invent an anchor; if no exact section fits, link the
closest page (or the docs home, `https://eventschedule.com/docs`). Never ship a feature bullet
without a user-guide link.
5. **Output.** Print the version as the title followed by the bullet body, as markdown in chat,
ready to paste into GitHub's release form.
Apply the repo's writing rules to the notes too: no em-dashes; "schedule" not "role"; "selfhost"
not "self-host".
**Example output:**
```
v1.0.112
- Added: OneSignal web push notifications so guests can opt in to event reminders. [Learn more](https://eventschedule.com/docs/account-settings)
- Updated: Custom dashboard links [#87](https://github.com/eventschedule/eventschedule/issues/87) [Learn more](https://eventschedule.com/docs/getting-started)
- Fixed: Markdown not formatting correctly in some event descriptions [#90](https://github.com/eventschedule/eventschedule/issues/90)
```
## Growth Data
When the user asks how to grow, what to build next, or how the funnel, conversion, retention, MRR
or sellers are doing, use the `growth-review` skill (`.claude/skills/growth-review/SKILL.md`).
- `php artisan app:pull-growth` downloads the pseudonymous growth payload from the hosted install
into `storage/app/growth/` (`latest.json`, plus a timestamped copy per pull) and prints the
headline numbers against the previous pull. It needs `GROWTH_DATA_TOKEN` in `.env`, the same value
as on the server. `--local` re-prints the summary without downloading.
- `php artisan app:pull-search-console` reads Google Search Console (read-only, a service account in
`SEARCH_CONSOLE_CREDENTIALS`) into `storage/app/growth/search-console/`: clicks and impressions by
page and query against the previous window, sitemap status, and `--inspect=<path>` for a page's
index status. Local only, never scheduled; `phpunit.xml` pins the credentials empty.
- `docs/GROWTH_DATA.md` defines every field, population and window, and lists the caveats. Read it
before drawing conclusions. Query the JSON with `jq` or `python3`; it is too big to Read.
- **Never commit a pull or any figure from one.** The repository is public. Conclusions and
hypotheses go to memory (`hub_growth_funnel`, `project_growth_experiments_ledger`).
- **Changing `GrowthExportService`'s output** means bumping `SCHEMA_VERSION` and updating
`docs/GROWTH_DATA.md` (`GrowthDataDictionaryTest` enforces both). Anything visitor-controlled must
pass the same anonymisation (`GrowthDataEndpointTest` holds the sentinel list).
## Feature Tiers (Free / Pro / Enterprise)
See `docs/FEATURES.md` for the complete reference of which features belong to each plan tier. **Always consult `docs/FEATURES.md`** when:
- Updating the pricing page, comparison/alternative pages, or feature marketing pages
- Updating the user guide or documentation
- Adding or modifying gate checks (`$role->isPro()`, `$role->isEnterprise()`) in the AP
- Writing feature descriptions that mention plan availability
## Architecture
### Multi-Tenant Routing
- **Hosted mode** (`IS_HOSTED=true`): Uses subdomains (`{subdomain}.eventschedule.com`)
- **Selfhosted mode** (`IS_HOSTED=false`): Uses path-based routing (`/{subdomain}/...`)
Routes are defined conditionally in `routes/web.php` based on `config('app.hosted')`.
### Key Directories
- `app/Services/` - Business logic (GoogleCalendarService, EmailService, EventGraphicGenerator)
- `app/Jobs/` - Background jobs for async operations (Google Calendar sync)
- `app/Utils/` - Helper utilities (MarkdownUtils, MoneyUtils)
- `app/Repos/` - Data repositories
### Core Models
- `User` - Authentication (supports Google/Facebook OAuth via Socialite)
- `Role` - Represents a **schedule** (called `Role` in code). The tenant in multi-tenant
- `Event` - Event details with markdown descriptions
- `Ticket` - Ticket types for events
- `Sale` - Purchase records with payment tracking
- `Group` - Represents a **sub-schedule** (called `Group` in code). Event categories within a schedule
### Frontend
- Use Vue.js for JavaScript functionality
### Important Integrations
- **Payments**: Stripe direct integration + Invoice Ninja
- **Google Calendar**: Bidirectional sync with webhook support (`app/Services/GoogleCalendarService.php`)
- **AI Features**: Google Gemini for event parsing and translation (`GEMINI_API_KEY`)
### Security
- CSP nonces for inline scripts: use `{!! nonce_attr() !!}` or `nonce="{{ csp_nonce() }}"`
- HTML Purifier for markdown content (XSS prevention)
- Environment-aware security headers in `app/Http/Middleware/SecurityHeaders.php`
- **Always encode IDs visible to users** - Use `UrlUtils::encodeId()` for IDs in URLs, and `UrlUtils::decodeId()` in controllers to decode them
### Scheduled Tasks
There are two interchangeable rails, and `CLAUDE.md`'s sync rule above exists because both must
list every command (`php artisan schedule:list` prints the scheduler rail):
- **`routes/console.php`** - the Laravel scheduler. Selfhost drives it with the documented crontab
entry `* * * * * php artisan schedule:run`; hosted drives it with `php artisan schedule:work` on
a DigitalOcean App Platform worker. See `docs/DIGITALOCEAN_WORKER.md`.
- **`AppController::translateData()`** - `GET /translate_data?secret=$APP_CRON_SECRET`, a complete
second copy of the schedule as cache-key-gated tiers. For installs that cannot run a cron
process, and as the hosted emergency fallback. Unsetting `APP_CRON_SECRET` disables it (and
`/release_tickets`).
The queue is drained by the `process-queue` entry inside the schedule, not by a resident worker, so
dispatch latency is up to about a minute. Both rails stamp `scheduler.last_run_at` every tick;
`AdminAlertService`'s `scheduler_stalled` row alerts when that goes stale.
**Deploying the hosted install is documented in `docs/NEXUS_RELEASE.md`.** The deploy is push to
`main`, then click Deploy in the DigitalOcean console - there is deliberately no maintenance
command to run, because the production database is not reachable from a dev machine and a
command that ships in the release cannot check the release before it deploys. Anything needing
production data is surfaced by the app instead: `AdminAlertService` on `/admin`, the Scheduler
card on `/admin/queue`, and `/up`, whose `DiagnosingHealth` listener round-trips the database
and the cache store. Production config for hosted is the DigitalOcean app spec, not any `.env`.
**Anonymous marketing HTML is edge-cached** - `CacheableMarketingResponse` strips the session and
CSRF cookies and sets `s-maxage=600` on cookie-free anonymous `marketing.*` GETs, so page views are
counted by a `sendBeacon` and first-touch attribution by a browser-written `es_attribution` cookie
rather than by the session. Read `docs/CACHING.md` before touching either, or before adding a
marketing page that renders anything visitor-specific.
**Hosted (eventschedule.com) runs `QUEUE_CONNECTION=database` and `CACHE_STORE=database`** (set on
2026-09-06, before the scheduler worker became a second container). Every scheduler mutex and every
cross-rail lock lives in the cache, so it must stay a store all containers share: on `file`, two
containers serialise against nothing. Laravel's database store never deletes an expired row unless
that key is read again, so `app:prune-cache` (hourly, both rails) is what keeps the `cache` table
bounded. Without it the per-visitor daily keys pile up for good, which is what turned up when the
1 GB MySQL started alerting on memory in October 2026.
## Environment Variables
Key configuration in `.env`:
- `IS_HOSTED` - `true` for SaaS, `false` for selfhosted
- `APP_TESTING` - Set to `true` in test environment
- `GEMINI_API_KEY` - For AI event parsing/translation
- `REPORT_ERRORS` - Enable Sentry error reporting
## Localization
Supported languages are defined in `config('app.supported_languages')`. Each has a corresponding directory in `resources/lang/`.
```bash
# Check for missing translation keys across all languages
php storage/check_translations.php
```
Run this periodically when adding new translation keys to ensure all language files are in sync.