physicshub.github.io
CLAUDE.md68 starsChanged 50 days agoMITView on GitHub
PhysicsHub — a free, open-source educational site of interactive physics simulations plus written theory. Next.js 15 App Router, React 19, p5.js for rendering, Tailwind v4, deployed as a static export to GitHub Pages (see "Two build modes"). Physics is our own engine (below), not a library; planck is still in package.json but no longer imported anywhere. There is no test suite. CI (.github/workflows/prchecks.yml) only runs prettier --check . and eslint . — run both before proposing a PR. A husky…
In this repository5 other files it gives its agents.
- AGENTS.mdAGENTS.md
- public/llms-full.txtllms.txt
- public/llms.txtllms.txt
- new-articleSkill
- new-simulationSkill
The file
We split this CLAUDE.md into the rules it gives an agent
6 rules. About 0 are common, 2 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 48
Import alias: @/* → repo root (e.g.
- Distinctive: line 73
Every simulation is built on it.
- Very specific: line 92
Import everything from the barrel: import { createSimulation, Gravity, Distance } from "../app/(core)/engine/index.js".
- Very specific: line 131
Coordinates: all physics is in meters, Y-up, origin bottom-left.
- Distinctive: line 163
When you change the project's structure or add a subsystem, update this file in the same change — it is the only architecture documentation …
- Very specific: line 164
When you change anything in app/(core)/engine/ or the way simulations are written, update .claude/skills/new-simulation/SKILL.md in the …
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project
PhysicsHub — a free, open-source educational site of interactive physics simulations plus written theory. Next.js 15 App Router, React 19, p5.js for rendering, Tailwind v4, deployed as a static export to GitHub Pages (see "Two build modes"). Physics is our own engine (below), not a library; planck is still in package.json but no longer imported anywhere.
Commands
npm run dev # next dev + nodemon regenerating the sitemap on routes.js changes
npm run build # generate:sitemap + generate:feeds, then next build — server mode, API routes live
npm run build:static # the GitHub Pages export: strips app/api, then exports to out/
npm run preview # build:static + serve out/
npm run lint # eslint (lint:fix to autofix)
npm run format # prettier --write . (format:check for CI parity)
npm run generate:sitemap
npm run generate:feeds # writes public/feed.xml + public/llms.txt + public/llms-full.txt
npm run contributors # regenerate the all-contributors table (needs GH_TOKEN)
npm run deploy # gh-pages -d outThere is no test suite. CI (.github/workflows/prchecks.yml) only runs prettier --check . and eslint . — run both before proposing a PR. A husky pre-commit hook runs scripts/check-package-lock.js and lint-staged. Node >= 24.
Versioning is fully automatic via semantic-release; never edit version in package.json. PR titles must be conventional commits (feat:, fix:, ci: …) — the squashed title becomes the changelog entry.
Architecture
Two build modes
output: "export" in next.config.js is conditional on app/api being absent, and that is the only switch:
npm run build—app/apipresent, so no static export: a normal Next.js app whose route handlers actually run. This is what Vercel builds and what you should run locally.npm run build:static—scripts/strip-api-for-static-export.jsmovesapp/apiaside (a rename),next buildproduces the export inout/, thenscripts/restore-api-after-static-export.jsmoves it back..github/workflows/release.ymluses this for the Pages deploy.
The strip exists because a static export cannot contain route handlers that read request-time data, and every app/api/auth/* handler reads cookies. Keying off the directory rather than an env var keeps CI, Vercel and every contributor's machine in agreement. If a build crashes mid-way app/api shows as deleted in git status — the next build:static restores it, or git checkout app/api does.
The consequence for features: anything under app/api does not exist on physicshub.github.io. The blog editor calls /api/publish with a relative URL, so publishing only works on the Vercel deploy or in next dev.
Route groups
app/(core)/— everything shared:engine/,components/,constants/,data/,hooks/,lib/,utils/,locales/,styles/. Not a route segment.app/(pages)/— the actual pages (about,account,blog,contribute,formulas,privacy,simulations).privacy/page.tsxis the privacy policy (a static server component, linked from the footer, the sign-in dialog and/account): when a feature starts sending data somewhere new — an analytics tool, a sign-in provider, an email sender, any third-party script — add it to its "Who else processes data" table and bumpLAST_UPDATEDin the same change.supabase/— the community database: SQL migrations + setup/moderation README (see "Community layer").app/api/auth/— GitHub OAuth:github/starts the flow (randomstatein a short-lived cookie),github/callback/verifiesstateand exchanges the code for a token,github/logout/clears it,me/reports the signed-in user. The token lives in anhttpOnlygh_sessioncookie (8h) viaapp/(core)/lib/githubSession.ts; needsGITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET.app/api/publish/route.ts— a POST handler that publishes a blog proposal as the signed-in contributor: it reads their token from the session, ensures their fork of the repo exists, branches from upstreammain, commits the JSON and opens a PR againstphysicshub. Returns401 { requiresAuth: true }when there is no session, which the editor turns into a sign-in redirect.- Import alias:
@/*→ repo root (e.g.@/app/(core)/data/chapters). Simulations undersimulations/use relative paths instead.
How a simulation is wired (the central pattern)
Writing or porting a simulation? Load the
new-simulationskill first (.claude/skills/new-simulation/SKILL.md). It is the step-by-step guide: the four files, the fullcreateSimulationcontract, the catalogue of every force, constraint and renderer, and the physics checks to run before calling it done. This section is the summary; the skill is the reference, and it must be updated whenever the engine changes.
Adding a simulation touches four places that must agree on the same name:
simulations/<Name>.jsx— the"use client"component. Loaded dynamically withssr: falsebyapp/(pages)/simulations/[id]/_components/SimulationWrapper.tsxviaimport("@/simulations/${id}").app/(core)/data/configs/<Name>.js— exportsINITIAL_INPUTS,INPUT_FIELDS(declarative form schema rendered bycomponents/inputs/DynamicInputs) andSimInfoMapper(state, context, refs). Configs describe the UI and the readout only: forces belong in the world, never here. EachINPUT_FIELDSentry carries a plain-namelabelplus optionalsymbol(physics symbol → accent pill) andunit(unit of measure → standardized chip) — units and symbols are never written intolabel. Anumberfield with bothminandmaxrenders as a slider + editable value box (the range bounds the slider only — a typed value may go beyond it and the thumb pins to the nearest end); without a full range it renders as a −/+ stepper. See the.sim-fieldsystem instyles/components/forms.cssand thenew-simulationskill.app/(core)/data/chapters.js— the catalog entry:link: "/simulations/<Name>", topical tags fromdata/tags.js, and the school-level contractlevel/alsoFor/difficulty(see below).[id]/page.tsxderivesgenerateStaticParamsand metadata from this file, withdynamicParams = false— a simulation missing fromchapters.jswill 404 in the export.
School levels. Every simulation is classified for an international school level so teachers can match it to their curriculum. data/tags.js exports LEVELS (elementary, lowerSecondary, upperSecondary, undergraduate, tool) with age range and curriculum equivalences (US grades, IGCSE, A-Level, IB), LEVEL_ORDER, and DIFFICULTIES (core/extended/advanced, the challenge within a level). The old EASY/MEDIUM/ADVANCED tags are gone. chapters.js entries carry level (primary band), optional alsoFor (bands the sim still works for), and difficulty. Articles in data/articles/ carry LEVELS/DIFFICULTIES objects in their tags array so the same classification shows on blog cards. 4. Optionally app/(core)/data/articles/<slug>.js, registered in articles/index.js.
School curricula (per-country levels). LEVELS is an age-based, international scale; each country cuts school differently, so the reader sees levels in their system. data/curricula.js defines CURRICULA — intl (the default: the LEVELS scale, unchanged) plus us, uk, in, au, it, sg — each an ordered list of stages (US High School, UK GCSE / A-Level, India Class 9–10 / 11–12, Australia Years 7–10 / 11–12, Italy biennio / triennio, Singapore Sec 3–4 O-Level / JC A-Level…) with a band linking it back to a LEVELS id. data/curriculumTopics.js is the audit table: TOPIC_PLACEMENTS (concept → curriculum → at(stage, grades, also?, note?)) and CONTENT_TOPICS ("sim:<Name>" / "blog:<slug>" → concept). getPlacement(item, curriculumId) resolves an item (a chapters.js entry, an article, or a Chapter props object) to { stage, grades, alsoStages, note }; an item with no topic entry falls back to its international level mapped through each stage's band (approximate: true), so a new sim/article still works everywhere — but add its CONTENT_TOPICS row, and a TOPIC_PLACEMENTS concept if none fits, when you add one, checking the placement against each country's real syllabus (the primary stage is where the item's depth is first taught, not where the concept is first mentioned; see the header of curriculumTopics.js). The reader's curriculum lives in hooks/useCurriculum.ts, a tiny useSyncExternalStore store (no provider): server and hydration render intl, then the client swaps in the saved manual choice (localStorage key physicshub-curriculum) or the detected one. Detection (utils/detectCurriculum.js) is offline and free of third parties — Intl time zone first, then navigator.languages region — never an IP lookup (static export, privacy). components/CurriculumSelector.jsx in the header is the override. Consumers: Chapter (level tile + tooltip, blog level tag via localizeTags), Search (its "School level" options are the active curriculum's stages, and levels the curriculum lacks are dropped from the selection), catalogFilters.js (getSimulationFacets/getBlogFacets(item, curriculumId) return stage ids; sortCatalog takes curriculumId for level order), FeaturedSimulations, the blog post header, and LevelBanner on /simulations/[id]. Server-only surfaces — metadata, JSON-LD educationalLevel, llms.txt, related-article ranking — deliberately keep the international level (crawlers see one stable answer). LevelBanner is a single line at every width so re-wording it after hydration can't cause layout shift.
Catalogue filtering & sort. components/Search.jsx is the one filter bar for both the /simulations and /blog indexes: a search field plus popover triggers (School level · Difficulty · Topic · Sort) that become bottom sheets under 768px. It takes dataset + getFacets (for per-option result counts) and emits { text, tags, levels, difficulties, sort } through onChange, round-tripping the whole state through the URL (?q=&levels=&difficulty=&tags=&sort=) so a narrowed view is shareable. The matching and sorting rules live in utils/catalogFilters.js — getSimulationFacets/getBlogFacets normalise an item to { levels, difficulties, topics } (blogs fold all three into their tags array, so they're classified by identity against LEVELS/DIFFICULTIES), facetMatches applies them (level/difficulty OR within a group, topics AND), and sortCatalog handles the SORT_OPTIONS (recommended / name / level / newest-oldest, with original order as the stable tie-break). Each page keeps only its own text matcher.
The engine (app/(core)/engine/) — the only physics core
Every simulation is built on it; there is no second way to do physics in this repo.
A World owns Bodies (state only — position, velocity, accumulated force) and Elements (all behaviour). Nothing subclasses a body: forces, constraints, colliders, renderers and pointer handlers are all elements, so behaviours compose by addition. A pendulum hanging off a projectile is one body with Gravity, one more body, and a Distance constraint between them.
engine/
World.js step order: beforeStep → applyForces → integrate → solve ×N → resolveCollisions → afterStep
Body.js state + params; `inverseMass` is 0 for fixed bodies, so anchors need no special case
integrators.js semiImplicitEuler (default), verlet, rk4 (for ODE systems like the double pendulum)
formulas.js pure textbook expressions — no Body, no World, no p5
forces/ Gravity Constant Wind Drag Buoyancy MutualGravity PointAttraction Damping Custom
constraints/ Distance Rope Strut Spring Bounds Ground SurfaceFriction Incline
Pin CircularPath LockAxis SpeedLimit
collision/ Collisions (impulse solver), collide1D, contactImpulse
render/ Shapes, ForceRenderer + ForceVectors + Vectors, Backdrop, colors
interaction/ Dragging
runtime/ createSimulation.jsxImport everything from the barrel: import { createSimulation, Gravity, Distance } from "../app/(core)/engine/index.js".
Rules that keep it composable — breaking any of them reintroduces the duplication this replaced:
- An element is a plain object with optional hooks, never a class hierarchy. Adding
renderputs it in the draw order (zIndex; bodies are 0, so negative draws behind them). AddingonPointerDown/Move/Up/onDoubleClickgets pointer events from the runtime. Write one inline in a simulation when the behaviour is genuinely one-off. - Every numeric option accepts a getter, resolved each frame via
utils/params.js:Gravity({ g: () => inputs.gravity }). Body params (mass,size,color,restitution,trail, …) accept getters too. Never re-sync parameters by hand in a draw loop, and never rebuild the world because a slider moved. - Forces only ever call
body.applyForce(f, label); the integrator converts to motion. The label records the force inbody.appliedForces, which is whatForceVectorsdraws — so a force can never be drawn differently from how it was integrated. Never compute a force a second time for rendering. - Constraints are positional:
solveprojects positions (repeatedsolverIterationstimes), thenafterStepcancels the constraint-violating velocity.Distancealso publishes its tension as a pseudo-force so it appears in free-body diagrams.
createSimulation(spec) owns all the boilerplate — input state and localStorage, reset/load controls, canvas setup and resize, the fixed-timestep loop, pointer dispatch, background, sim-info panel. A simulation supplies only config and build({ world, p, inputs, bounds, refs, infoRefs, setOverlay, setInput }), plus optional update, draw, info, overlay, world and simInfoRefs. build re-runs on every resize, so it must be idempotent. inputs is a live proxy: safe to capture in a closure, always current. setInput(name, value) writes an input back from the sketch (the slider follows and the next frame already reads the new value) — for when dragging something on the canvas is changing a parameter, as in RayTracing.jsx.
SimplePendulum.jsx (constraint as pendulum), ParabolicMotion.jsx (analytic guide vs numerical flight) and InclinedPlane.jsx (emergent normal force) are the clearest references — read one before writing a new simulation.
Three simulations deliberately bypass the force/integrate pipeline, and the reason is always physical rather than convenience:
DoublePendulum.jsx— two rigid rods leave two degrees of freedom, so it integrates the exact Lagrangian equations for (θ₁, θ₂) withrk4. A constraint solver on free point masses would be far less accurate, and the system is chaotic enough to amplify that.PiCollisions.jsx— the collision count is the answer (the digits of π), so it must be exact. An inline element advances the blocks event by event, solving for each contact time, so no collision can be missed.ThreeBody.jsx— uses the normal pipeline but withworld: { substeps: 40 }, because close approaches are too stiff for a single 1/120 s step.
Bodies those elements position themselves are marked kinematic: true, which tells World to skip integration while still recording forces.
Two more have no forces at all and draw in pixel space from the draw hook: TrigonometricCircle.jsx (pure maths) and RayTracing.jsx (geometric optics — a Whitted ray tracer; the image is cached and re-traced progressively only when the scene changes, since a full trace per frame is far too slow).
The one piece of physics outside the engine is the landing hero preview (components/HeroPreview.tsx + components/heroScenes.ts): a single canvas rotating through tiny self-contained scenes (double pendulum, figure-eight three-body, projectile; each visit opens on a random one, picked after mount so hydration matches), each with exact equations and RK4 or closed-form kinematics. It stays out of the engine on purpose so the homepage never loads p5 or the engine for a teaser; it is not a way to write simulations. A scene is { id, label, caption, href, readout, prewarm, create(perturb) }, with label/caption marked /* i18n */. The active tab's CSS progress animation drives the rotation (its animationend advances it), so hover, focus, off-screen and the pause button all pause it; under reduced motion each scene is a still frame and nothing rotates.
GitHub stats & the contributors CLS. hooks/useRepoStats.ts is a useSyncExternalStore store (no provider, same pattern as useCurriculum) holding the repo's star count and contributor count: it fetches once, mirrors the result to localStorage (physicshub-repo-stats) and serves the server/hydration pass an empty value. GitHubHeaderBadge (mounted on every route) feeds it; /contribute reads it so ContributorsSection and ContributorsSectionSkeleton can print the (N) in the title and render N placeholder cards before the real list loads. A skeleton card is a real .contributor-card with its text swapped for bars, so it has the same box as the card replacing it — keep it that way when the card changes.
Shareable state & embed mode. A simulation's inputs round-trip through its URL, and utils/simulationUrl.js is the only code that reads or writes them, and the one trust boundary for inputs from anywhere else (URL, uploaded JSON, localStorage, community presets, cloud configs): parseInputsFromSearch(search, INITIAL_INPUTS, INPUT_FIELDS) and its object twin sanitizeInputs both go through acceptValue, which keeps only keys that exist in INITIAL_INPUTS (so fbclid, utm_* and embed never leak into the inputs), coerces to the default's type, accepts a select only with one of its option values (returned in the option's own type — <select> reports numbers as strings), a colour only as hex (it reaches inline CSS, where url(...) would leak viewers' IPs), and any other string only as a short plain token; createSimulation's handleLoad sanitises everything it loads; buildSimulationUrl(path, inputs, INITIAL_INPUTS, { embed }) writes only the values that differ from their defaults. useSimulationState loads URL → localStorage (merged over the defaults) → INITIAL_INPUTS; createSimulation passes initialInputs down to Controls, whose share and embed buttons both use the builder. ?embed=1 is the iframe view: the inline script at the top of <body> in app/layout.tsx sets body[data-embed="1"] before first paint, and simulation.css then hides the header, footer, breadcrumb, level banner, top bar, overview and related cards, sizes the stage to the viewport, and shows the "Open on PhysicsHub" link (.embed-open-link, always rendered by SimulationLayout, visible only in embed). Embed mode is CSS-only, never a React branch, so there is no flash or layout shift.
Community layer (accounts, presets, likes, cloud-saved configs). Runs on one free Supabase project, called directly from the browser with the public anon key — so it works on the static GitHub Pages export too, unlike app/api. All rules live in the database (supabase/migrations/*.sql, applied in order; supabase/README.md has the threat → defence table, setup, dashboard hardening and moderation): RLS, column grants (users can never write featured/hidden/counters; anon reads only public columns), per-user rate limits on every write via private.hit_rate_limit (a schema the Data API doesn't expose; cascades are exempt), strict is_valid_inputs on preset/config payloads, no links in preset text, presets immutable once published, unique non-impersonating nicknames, no self-likes, reports only from accounts ≥ 24 h old, auto-hide after 3 reports, and delete_my_account() for GDPR self-service. A new write path needs its own limiter trigger. Every simulation ships one or two featured starter presets, seeded by supabase/migrations/20260926000000_official_presets.sql and signed by a banned, password-less "PhysicsHub" system account (the only profile allowed the reserved name) — give a new simulation its own in a follow-up migration using the same author_id. Personal data on screen (account menu, /account, sign-in form) carries data-clarity-mask so Microsoft Clarity recordings never capture it. Without NEXT_PUBLIC_SUPABASE_URL/NEXT_PUBLIC_SUPABASE_ANON_KEY everything switches itself off (isCommunityEnabled in lib/supabase.ts is a build-time constant, so there's no layout shift either way). Pieces: lib/supabase.ts (async client — the SDK is a dynamic import, never in the first-load bundle, and useAuth doesn't load it at all for visitors with no stored session or ?code=), lib/community.ts (every query — components never build their own; its header is the request budget: a simulation's presets are fetched once per visit, only when CommunityPresets nears the viewport, up to PRESET_FETCH_LIMIT, cached 5 min and de-duplicated in flight, with Top/New and "Show more" computed client-side by sortPresets; saved configs arrive in one request per session; likes lookups are keyed on the list's ids and user.id, never on the user object. Keep it that way: the anon key is public and every request counts against the free quota), hooks/useAuth.ts (a useSyncExternalStore store like useCurriculum; also owns the global sign-in dialog via openSignIn()), components/account/AccountMenu.tsx (header; mounts SignInDialog), /account (nickname, own presets, data export, account deletion, privacy summary; noindex). Sign-in is GitHub / Google OAuth or an email magic link, PKCE, returning to the page that started it (the ?code= is stripped after). Profiles hold only a public nickname (default: GitHub handle or a Physicist-xxxxxx pseudonym — never a real name, many users are students). On /simulations/[id], _components/CommunityPresets.tsx is the community slot of SimulationWrapper, right under the stage and before overview and related (it loads client-side, so it must not push them: cards have one fixed height, --preset-card-height, with their content clamped; the grid always shows exactly PRESET_PAGE_ROWS rows — skeletons while loading, clamped per breakpoint in CSS, then presets plus an invitation card in the first free slot — and "Show more" adds rows; its row is always rendered, only hidden, so its height is reserved too); page.tsx hands it each field's label/unit/options plus INITIAL_INPUTS so cards show only what a preset changes (presetFormat.ts). The running simulation and that section are separate React trees and talk through window events in utils/simulationEvents.js: applyInputs ("Try it"), getCurrentInputs (the publish dialog reads what's on screen — synchronous dispatchEvent), notifyPresetsChanged. hooks/useExternalInputs.ts, called by createSimulation, answers them and also loads the user's cloud-saved config once signed in (URL inputs still win); SaveButton/DeleteButton write both localStorage and the account. Anything arriving as JSON goes through sanitizeInputs (the object twin of parseInputsFromSearch). The publish action lives in the section, not the toolbar, so the toolbar keeps the geometry the skeleton reserves.
Catalogue cards. Chapter makes the whole card one click target with a stretched link: the CTA's ::after covers .chapter-card. Don't give .chapter-card-body (or any wrapper between the card and the CTA) a position, or the overlay shrinks to that wrapper and the thumbnail stops being clickable.
Dialogs. components/Popup.tsx is the site's only modal (feedback, share link, download, sign-in, publish preset, landing CTAs) — don't build another. Props: isOpen, onClose, popupContent: { title, description?, buttons? }, optional icon (accent badge by the title) and size ("sm" 26rem / "md" 32rem); children go in a body that scrolls on its own while header and footer stay put. A footer button can be a link (href) or submit a form in the body (form: "<form id>"). It handles Escape, backdrop click, focus trap and restore, and scroll lock, and docks to the bottom as a sheet under 640px. Build dialog content from the primitives in styles/components/popup.css — .popup-stack, .popup-field (+ __label/__optional/__count), .popup-input, .popup-inline, .popup-label, .popup-note, .popup-divider, .popup-alert--error|success|info — rather than per-dialog form CSS.
Navigation watchdog. A client-side Link navigation waits for the next route's RSC payload (/<route>.txt?_rsc=…) before anything changes, and on GitHub Pages that fetch sometimes stays pending — the click looks ignored. components/NavigationWatchdog.tsx (mounted once in Layout.jsx) watches clicks on internal links in the capture phase, shows a thin top progress bar (html[data-navigating], styles/layout/navigation-progress.css), and if the pathname hasn't changed after 4 s does a full page load of the same URL. It relies on no site <a href> cancelling its click without navigating — keep it that way (use a <button> for in-page actions).
Conventions that still hold
- Coordinates: all physics is in meters, Y-up, origin bottom-left. Conversion to screen space (Y-down, pixels) happens only at render time, through
constants/Utils.js(toPixels/toMeters/physicsToScreen,SCALEfromconstants/Config.js) orengine/render/Shapes.js.Utils.jsholds a module-globalCANVAS_HEIGHTset bysetCanvasHeight(). - Time:
constants/Time.jsis a module-level singleton owning timeScale, pause, manual stepping, and per-p5-instance fixed-timestep accumulators (FIXED_DT = 1/120).computeSteps(p)→{ dt, steps }is the only scheduler;createSimulationalready calls it, so a simulation never touches time directly.
Content & SEO
scripts/sitemap-generator.js reads routes.js + data/articles/index.js + data/chapters.js, writes public/sitemap.xml + public/robots.txt and rewrites routes.js in place — so routes.js diffs (lastmod churn) are expected build output, not hand edits. It strips the duplicate XML prolog the sitemap + xml-formatter combo would otherwise emit, and assertValidSitemap() fails the build if the output is not well-formed. NOINDEX_PATHS (currently /blog/create, /simulations/test, /account) are excluded from the sitemap and routes.js and emitted as Disallow in robots.txt; robots.txt also carries explicit Allow groups for the major AI crawlers (GPTBot, ClaudeBot, PerplexityBot, Google-Extended, …). Blog lastmod is blog.updated || blog.date (DD/MM/YYYY); everything else is the build date.
scripts/generate-feeds.js (also run before every next build) validates the Formulary (see "Formulary") and writes public/feed.xml (Atom 1.0, newest-first blog feed; discoverable via alternates.types in blog/layout.tsx), public/llms.txt (the llmstxt.org site map for LLM agents — articles, simulations, per-sim concept summaries), and public/llms-full.txt (expanded simulation controls, concepts, and formulas derived from simulationOverviews.js). All three are build output like sitemap.xml/robots.txt — regenerated, not hand-edited, and committed with diff churn.
Google Indexing API. scripts/submit-indexing.js (npm run submit:indexing) pings Google's Web Search Indexing API (urlNotifications:publish, URL_UPDATED) with every <loc> from the sitemap — by default the live one at physicshub.github.io/sitemap.xml, overridable with --sitemap <url|path>; pass explicit URLs as positional args, --dry-run to just list, --type URL_DELETED for removals. Zero deps (node:crypto JWT + fetch). Credentials resolve as GOOGLE_INDEXING_CREDENTIALS (raw JSON, used by CI) → INDEXING_KEY_FILE → the lone *.json in .secrets/ (git-ignored, local dev). The service-account email must be a verified Owner of the Search Console property or every publish 403s. .github/workflows/submit-indexing.yml runs it weekly (Mon 06:00 UTC) + on manual dispatch, reading the GOOGLE_INDEXING_CREDENTIALS repo secret. Note the API is officially scoped to JobPosting/BroadcastEvent pages — it works for ordinary pages but Google may ignore the ping. IndexNow (Bing/Yandex) is not wired up.
Metadata & structured data. Page metadata is Next's App Router metadata / generateMetadata. Client-only pages (/simulations, /blog, /about, /contribute, /blog/create) carry their metadata in a sibling server layout.tsx — each sets a self-referencing alternates.canonical; /blog/create also sets robots.index:false. /simulations/[id] and /blog/[slug] set their own canonical/robots in generateMetadata (they'd otherwise inherit the listing's). app/layout.tsx sets title.template ("%s | PhysicsHub") — child routes must not re-add | PhysicsHub in their own title; an intermediate layout.tsx with children (blog/, simulations/) sets title as { absolute, template } so the suffix keeps cascading. OG/Twitter titles are not templated — set the full-brand form there explicitly. Use the public name PhysicsHub in every meta field. SITE_URL / SITE_NAME / ORG_ID / WEBSITE_ID come from app/(core)/constants/site.js (also imported by the build scripts). The OG image is /Thumbnail.jpg (1200×800). JSON-LD: app/layout.tsx emits Organization + WebSite (#organization / #website; WebSite carries a SearchAction); /simulations/[id] adds LearningResource + BreadcrumbList; /blog/[slug] adds BlogPosting+LearningResource (dual type — wordCount, timeRequired, datePublished/dateModified from date/updated, about/keywords/articleSection from tags, abstract from the key-fact callout, breadcrumb ref) + BreadcrumbList (with @id) + an optional Person node + an FAQPage node when the article has a faq block. Article authorship: app/(core)/data/authors.js (resolveAuthor / authorSchema) — default is the org-backed community byline; an article sets author: "<key>" to sign with a real Person. The server-side FAQ/abstract extraction lives in app/(core)/utils/blogSchema.ts (walks theory.sections[].blocks like getTitles — never imports the client renderer).
Simulation page content. /simulations/[id] renders SimulationOverview (server component) — an <h1>, intro, "what you can change", key concepts, compact Formulary cards (KaTeX, variable chips with units, a link to the full card), and a link to the related article. Its copy lives in app/(core)/data/simulationOverviews.js, keyed by the URL segment, and its formulas are Formulary ids (see "Formulary"); relatedBlogSlug in chapters.js must match a real article slug in data/articles/index.js. page.tsx passes <SimulationOverview> as the overview prop of SimulationWrapper, which renders it as a sibling after the client-only simulation (not inside it), so it is in the server HTML and its space is reserved before the stage mounts. The full article is not embedded on simulation pages any more: the page closes with RelatedArticles (_components/RelatedArticles.tsx, the related slot, placed the same way) — a server component that ranks, then hands plain card data to the client RelatedArticleCards, which renders the same Chapter card as the /blog index in its detailed form (detailed un-hides the metric tiles: school level, difficulty, readingTime; card images come from utils/blogImages.js, shared with /blog). Recommended articles are ranked by utils/relatedArticles.js — the simulation's own relatedBlogSlug article first, then articles whose title shares a distinctive word of the simulation's name, then shared topic tags weighted by rarity, then level fit and recency; articles with nothing in common are never suggested, and the section disappears if none qualify.
No layout shift on simulation pages (CLS). The simulation is ssr:false, so the server HTML holds only a skeleton for the stage; anything that changes size when the real stage replaces it counts as CLS (this page group scored > 0.25 in Search Console). Rules: (1) SimulationWrapper creates each dynamic() component once, at module level (simulations map) — calling dynamic() in the render body remounts the simulation on every useTranslation state update; (2) the loading fallback is _components/SimulationSkeleton.tsx, which reuses the real classes (.top-nav-sim, .simulation-stage, .screen, .inputs-container) and reserves the parameters panel from the fields prop (page.tsx derives "slider" | "compact" per INPUT_FIELDS entry) — keep it in step with TopSim/SimulationLayout/DynamicInputs geometry; (3) narrow-screen behaviour of TopSim/Back is CSS (max-width: 767px), never a useMobile() branch, which starts false and flips after mount; (4) the "did you know" toast is position: absolute at every width. When adding UI to the shell, give it its final size on first paint.
Simulation shell layout. SimulationLayout wraps the canvas and the controls+parameters in .simulation-stage: below 1080px everything stacks (canvas → toolbar → parameters → community presets → overview → recommended reading); at ≥1080px it is a CSS grid with the canvas as a tall left stage and .simulation-stage__panel — the playback toolbar plus DynamicInputs — docked as a right rail exactly as tall as the canvas (both read --sim-stage-height / --sim-stage-min-height set on .simulation-stage; overflowing parameters scroll inside the panel, and the .inputs-container grows to fill spare height so the bottom edges align). The full-width bands (.simulation-breadcrumb, .simulation-level-banner, .top-nav-sim, .simulation-stage) share --sim-max-width / --sim-gutter / --sim-panel-width (declared on .simulation-page) so they align — and so do the bands under the stage: .simulation-overview and .simulation-related use the same inset width (never a narrower centred column), the overview reuses the stage's two-column grid (__main = title/intro/formulas under the canvas, __aside = controls/concepts/theory link under the parameters panel), and the related cards are 1/2/3 columns. The stage height is viewport-driven (min(78vh, 780px) wide, min(64vh, 560px) stacked); .screen fills .simulation-stage__canvas. The old mobile controls drawer is retired — every control is always visible and wraps. All styles are in styles/components/simulation.css; the parameter panel — .inputs-container (the grid) and the .sim-field control system every input shares (range / stepper / select / toggle / color, each with a symbol pill and a unit chip in its head) — is in forms.css.
Formulary. Every formula on the site is an object with an "identity card", in one registry: app/(core)/data/formulas/ (one file per domain; index.js exports DOMAINS in page order, formulas, formulaById, getFormula, resolveFormulaRef, formulaHref). A card has id (permanent — it is the /formulas#<id> anchor and the ref key), name, aka, latex, summary (inline syntax allowed), variables (key, latex, name, SI unit, a typical value, constant for physical constants, angle for degrees-in/radians-solved, min/max), validity, tags/level/difficulty (the same classification as a simulation, so getFormulaFacets is getSimulationFacets), related, and optionally solve (one plain function per solvable variable, or { from, fn } when it needs only some inputs; never an expression string), note, example, pitfalls, history. References to cards: simulationOverviews.js formulas (an id, or { ref, label?, latex? } to show the simulation's own form); an article's { type: "formula", ref, latex? } block (the LaTeX falls back to the card's, and a link to the card appears under it); inline [[id]] in prose. Where a card is used is never written on the card: utils/formulaUsage.js derives it (buildFormulaUsage) from those refs, and validateFormulas — run by scripts/generate-feeds.js before every build — fails on an unknown ref, a duplicate id, an unknown related, or a solve key that isn't a variable. /formulas is one page: page.tsx (server — metadata, DefinedTermSet JSON-LD with one DefinedTerm per card, computes the usage since that needs every article) renders the client _components/Formulary.jsx: a sticky domain index on the left from 1024px (scroll-spy via IntersectionObserver on [data-domain] sections; domain chips replace it on narrow screens) and a main column with the shared Search (sortOptions={UNDATED_SORT_OPTIONS}: formulas have no dates; its .filterbar padding is reset there so it aligns with the headings) over the domain groups. On a #<id> hash it opens, scrolls to and flashes that entry, keeping it visible even when a filter would hide it. Entries are full-width spec sheets, not a card grid: components/formulas/FormulaCard.tsx (.fx) puts the formula on a blueprint-grid plate (copy-LaTeX button) beside name, other names, summary, symbol legend and a quiet meta line (level, difficulty, topics, "Used in N"), with the identity card in a native <details> (crawlable, closed on first paint, so no CLS) whose body is two columns — variables, constants, when it holds, note, example, pitfalls, history | calculator, related formulas, used in. FormulaCalculator.tsx (.fx-calc) is a readout panel: segmented "solve for", the result in mono, then the inputs with unit chips. The compact cards on simulation pages are .fx-mini (plate + name + symbol/name/unit legend + link). Styles: styles/components/formulas.css. llms.txt lists the Formulary; llms-full.txt resolves each simulation's formula refs.
content/blogs/*.json holds community-submitted blog proposals created by the publish API; curated articles live as JS modules in app/(core)/data/articles/. Each carries a date (DD/MM/YYYY, first published) and an optional updated (DD/MM/YYYY, last substantive edit) — they feed the byline, datePublished/dateModified, OG times, and the sitemap/feed lastmod. Writing or restructuring an article? Load .claude/skills/new-article/SKILL.md first — it is the answer-first house style (question-shaped name, front-loaded answer, key callout + takeaways + faq, length targets per archetype) and the rule that code blocks are allowed only in articles tagged TAGS.PROGRAMMING.
Article prose & the theory renderer. components/theory/ renders a theory document (an array of sections, each a list of typed blocks — paragraph, sectionTitle, subtitle, callout (calloutType info/tip/warning/success/key), formula, list, table, image, toggle, takeaways, faq, …). takeaways ({ items: string[] }) is a TL;DR box; faq ({ items: [{q,a}] }) is a visible Q&A list that also drives FAQPage JSON-LD via utils/blogSchema.ts — markup and schema share one items array so they can't drift. Running-text fields (paragraph/list/callout/toggle/takeaways/faq bodies and table cells + headers) are parsed by parseInlineText in components/theory/utils.tsx for a small inline syntax: $…$ → KaTeX inline math, **bold**, `code`, [label](url), and [[formula-id]] → a link to that Formulary card — single *italics* are not supported. Add an inline form there, not per block. Adding a block type touches: TheoryRenderer.tsx switch, theory/types.ts, theory/index.ts, the /blog/create palette (NEW_BLOCK_TEMPLATES + a toolbar button + the handleAddBlock translate loop), styles/components/theory.css + blog-editor.css, utils/blogSchema.ts if it carries schema, and the new-article skill + this file. The article view (/blog/[slug]) is styled as a long-form reading surface — held ~70ch measure (.blog-main-column), bright body text, one vertical rhythm — in styles/components/theory.css; block markup and classes are shared with the /blog/create editor, whose editing affordances live in styles/components/blog-editor.css.
i18n
Custom, not a library: hooks/useTranslation.ts reads the Google Translate googtrans cookie to pick a language, loads app/(core)/locales/<lang>.json, and locales/meta.json marks which languages are completed. Incomplete languages fall back to Google Translate widget behaviour (notranslate is applied when a locale is complete) — so a key missing from a completed locale shows in English. npm run i18n:extract (scripts/i18n-extract.js, zero deps) finds every string literal passed to t(), every literal inside a popupContent={{…}} prop (Popup translates those itself) and every literal marked /* i18n */ "…" (strings that reach t() through a variable, e.g. the error messages in lib/community.ts); it appends missing keys to en.json and lists what each completed locale lacks, which you then translate by hand. --check only reports and exits 1 if anything is missing. Run it whenever you add UI copy.
Conventions
- Mixed JS/TS by design; new shared code trends toward
.tsx/.ts, simulations stay.jsx. The engine is.jswith JSDoc types, so it stays readable to contributors writing plain-JS simulations. - When you change the project's structure or add a subsystem, update this file in the same change — it is the only architecture documentation contributors (and their Claude) get.
- When you change anything in
app/(core)/engine/or the way simulations are written, update.claude/skills/new-simulation/SKILL.mdin the same change too. It is checked in, so every contributor's Claude loads it, and a stale catalogue there produces broken simulations elsewhere. - Likewise, when you change the theory block types, the article object shape, or the blog schema pipeline, update
.claude/skills/new-article/SKILL.mdandAGENTS.mdin the same change. - Some comments are in Italian — fine to keep, write new ones in English.
- Significant UI changes: add screenshots under
public/screenshots/<NEW_VERSION>/, homepage shot namedmain.png.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

