Appearance
React and Material UI web architecture
This article is for ShootCal maintainers and AI coding agents. It describes the authenticated web app at app.shootcal.com, not the public marketing site or the separate ShootCal Galleries application.
The visible authenticated workspace runs on React 19 and Material UI Core. ShootCal did not replace its proven application logic with a monolithic client-side rewrite. Instead, React owns the interface while a retained controller layer owns authentication, navigation state, API orchestration, and operations where a duplicate or ambiguous retry could affect a client, calendar, email, contract, or payment record.
Repository and source of truth
- Canonical repository:
/Users/ryansmith/shootcal-web - React source:
react/src/ - Shared React entry point and bridge publication:
react/src/main.jsx - Shared Material UI theme and wrappers:
react/src/ui/ - Existing application controllers:
public/assets/js/app.js?v=74ae709197 - Generated React output:
public/app/rcal/ - Deployment and verification:
deploy.shandtools/check-*
Current Git source and a verified live asset always outrank this article. Before changing or describing production behavior, check the repository's AGENTS.md, react/BRIEFING.md, current Git state, source-hash marker, and deployed asset version.
The three web layers
1. React and Material UI presentation
React components render the calendar and authenticated product surfaces, including clients, booking requests, session types, studio defaults, questionnaires, contracts, invoices, expenses, products and services, reports, settings, and shared workspace navigation.
Material UI supplies accessible controls, dialogs, cards, menus, tabs, fields, and responsive layout. ShootCal uses a scoped ThemeProvider; it does not install a global MUI reset over older markup. The shared theme sets the light-only palette, system typography, focus-visible treatment, reduced-motion behavior, forced-colors support, minimum touch targets, and common control geometry.
2. Controller and bridge layer
public/assets/js/app.js?v=74ae709197 remains the compatibility and safety controller. Depending on the surface, it may still own:
- session bootstrap, sign-in state, routing, and product navigation state;
- API loading, canonical validation, and server error interpretation;
- Google Calendar, Contacts, Tasks, Drive, and Gmail coordination;
- idempotency keys, retained recovery records, and cross-tab operation locks;
- invoice, contract, booking, and client actions whose semantics were already proven;
- confirmations, email review steps, and recovery after ambiguous network results.
React components normally receive serializable data and callbacks. They must not quietly duplicate a controller's fetch, money calculation, validation rule, email action, or durable-operation body merely to make a component feel more self-contained.
3. Same-origin PHP API
The browser calls /api, which the ShootCal web origin forwards to the PHP backend. Requests use the existing first-party session cookie. The browser does not receive a Google OAuth token and does not choose a trusted user or calendar owner in request data.
The backend remains authoritative for authorization, persistence, Google access, invoice and contract semantics, booking workflows, and idempotent recovery. A React conversion changes presentation ownership; it does not move trust into the browser.
How an island mounts
The React bundle is loaded as a module. Classic application code may need to request a render before that module finishes loading, so every migrated surface uses a published bridge and a pending object:
app.jsfinds or creates the host node and prepares the current props and callbacks.- If the React bridge already exists,
app.jscalls it immediately. - If the bundle is still loading,
app.jsstores the latest pending render object. main.jsxcreates the bridge, publishes it onwindow, then consumes the pending object synchronously.
createIslandRootRegistry guarantees one React root per host node and provides an explicit unmount boundary before legacy code replaces a pane. createLazyIslandLifecycle gives each node a render sequence so a slower, older dynamic import cannot paint over newer state. Failed lazy imports are not permanently cached; the visible recovery action can request the chunk again.
Do not create a second root for an existing host, replace a mounted host with innerHTML without unmounting, or publish an incomplete bridge and fill it in later. Those patterns create duplicate roots, stale paints, and classic-script/module races.
Calendar ownership is special
The calendar is a long-lived embedded React surface rather than a short-lived projection. window.scRcal is its public bridge. The controller provides demo, limited-account, settings, and invoice hand-off state before mount; the calendar publishes refresh, navigation, cache, and deep-link operations back to the shell.
Important contracts:
- A drag only opens a confirmation. Dropping an event is never itself a write.
- A date-only PATCH must preserve the event's existing title, location, and description.
monthEventsFor(dayKey)returnsnullwhen a month is not cached;nulldoes not mean an empty month.- Demo reads use the public demo feed because an anonymous demo has no authenticated session.
- The calendar remains mounted while another product area is visible so its state survives tab changes.
Durable operations and fail-closed behavior
Creates, sends, and other nontrivial mutations use one idempotency identity per user action, not per network attempt. If the result is ambiguous, the app retains enough local information to inspect or recover the same operation instead of generating a second one.
Operations that can run from two tabs use the Web Locks API through the shared durable-operation wrapper. If the browser cannot provide the required lock, those paths fail closed rather than pretending that local in-memory locking is equivalent. React may render the busy, recovery, success, or error state, but the controller owns the lock, attempt record, retry classification, and authoritative body.
Never add an automatic retry for an ambiguous Gmail send, calendar write, invoice issue, contract send, booking acceptance, or similar customer-facing action. Recovery must reuse the original operation identity and prove its outcome.
Material UI policy
ShootCal deliberately uses MUI Core Community only.
- Import individual component paths; do not import the
@mui/materialbarrel. - Do not add MUI X Pro/Premium,
@mui/x-license, or the MUI icons package. - Reuse ShootCal's existing SVG icons.
- Do not add
CssBaseline,InitColorSchemeScript, or another global reset while non-MUI markup still shares the page. - Use ShootCal wrappers for checkbox, switch, and text-field controls so older form CSS cannot restyle their native inputs.
- Put MUI system values under
sx; do not forward layout props onto DOM nodes. - Keep the authenticated app light-only unless the product decision changes explicitly.
tools/check-mui-policy.mjs enforces these boundaries during builds and deployments.
Rich text and lazy feature chunks
Contract and product text editing uses self-hosted React/Tiptap components. Do not reintroduce CDN editors or treat legacy plain-text bodies as already-sanitized HTML. Existing conversion and server sanitization rules remain authoritative.
Larger product surfaces are lazy-loaded into content-hashed chunks. The fixed rcal.js and rcal.css entry files receive a deployment version, while their hashed chunks can be cached immutably. Application HTML and API responses are not immutable.
Build, test, and deploy contract
The repository requires Node 22.12 or newer. From the repository root:
sh
npm --prefix react test
npm --prefix react run buildThe build runs the Node-version check, MUI policy guard, Vite, React source-hash writer, and bundle verification. The generated public/app/rcal/ output is committed. Editing react/src/ without rebuilding is a release error.
deploy.sh runs the full PHP and Node guard suite, checks the React source hash and bundle, runs React tests, stages versioned assets, verifies the Nginx candidate, deploys from the canonical checkout, and performs origin and edge checks. Do not deploy from the SSD recovery mirror or a dirty shared checkout.
When changing one migrated surface, run its focused component tests and matching tools/check-react-*.mjs guard in addition to the full build. Visual verification should include the real authenticated route at phone, tablet, and desktop widths, with console errors and horizontal overflow checked.
Safe workflow for AI agents
- Read
AGENTS.md,react/BRIEFING.md, the component, its focused tests, the controller bridge, and the matching deployment guard before editing. - Identify which layer owns data, validation, mutation, confirmation, and recovery. Do not infer ownership from what is visible on screen.
- Preserve callback payloads and absence-vs-blank semantics. Some PATCH fields intentionally mean “leave unchanged” when omitted and “clear” when present but empty.
- Keep presentation migrations incremental. A visual rewrite does not authorize replacing backend, financial, email, calendar, or recovery semantics.
- Rebuild the React bundle, run focused and full tests, verify the source hash, and inspect the generated diff.
- Confirm the exact deployed commit and live asset before saying a migration is production-complete.
What “fully React and MUI” means
It means the authenticated product's visible workspace and controls are rendered by React and Material UI across its migrated surfaces. It does not mean every line in app.js should be deleted, every operation should fetch directly from a component, or the PHP backend has become optional.
The retained controller is intentional. It protects established authorization, Google integration, delivery, financial, and recovery behavior while React and MUI provide the consistent interface. Remove a controller path only after its replacement has an explicit contract, parity tests, recovery coverage, and a verified production rollout.