Loopwise Docs
Guides

Custom CSS & JavaScript

The contract for customizing a Loopwise storefront — stable DOM selectors, lifecycle events, SCSS semantics, the @import trap, how custom JavaScript is processed, and cache propagation.

Loopwise storefronts can be customized with your own CSS and JavaScript. This page is the contract for that customization: the DOM selectors you can rely on, how your CSS and JavaScript are processed before they reach the browser, and how long changes take to go live.

Set custom code in the school admin under 學院設定 → 網站外觀 → 進階選項 (Settings → Site appearance → Advanced), in the 自訂 CSS and 自訂 JavaScript fields.

Stable DOM selectors

The selectors below are a published contract. They are covered by a test that fails if any of them disappears from the storefront, so header/footer injection and other customizations that target them keep working across releases.

SelectorWhat it is
.navbar-wrapperThe sticky wrapper around the storefront navbar. To insert a header, use the slots inside it (below) rather than the wrapper's own children.
.footer-wrapperThe wrapper around the footer. Present on every content page and on lecturer profile pages (/@slug), even when there is no platform footer to show (the element stays, its contents are empty). To insert a footer, use the slots inside it.
main#contentThe main content landmark (<main id="content">) that wraps every page body between the navbar and footer.
.setting-bannerThe 200px page banner on settings-style content pages.
.purchased-cardThe identity class on each card in the learner's "My Courses" list.
.mc-page, .mc-toolbar, .mc-sidebar, .mc-course-grid, .mc-course-card, .mc-empty-state, .mc-paginationRegion hooks for the "My Courses" redesign — page root, toolbar, filter sidebar, course grid, an individual card, the empty state, and pagination.
.mc-card-cover, .mc-card-titleTwo internals of an individual .mc-course-card: the cover-image wrapper and the course title. Other card internals (rating, footer, progress, CTA) are not a contract — target only these two.

Any class or id not listed here is not a contract. Tailwind utility classes, hashed CSS-module names, and internal wrappers can change at any time — never target them.

Insertion slots

Each shell exposes empty elements the platform never renders into. Append your own markup to a slot instead of inserting next to (or inside) the platform's element — the slot's position is a promise, the platform element's internals are not. Beside each pair of slots, a data-loopwise marks the platform's own piece so your CSS can hide it when your version replaces it. (Both are platform-owned attributes: data-slot and data-part also appear on component internals and are not a contract.)

Storefront — content pages, and /@slug lecturer profiles, which share the hook names but are their own shell (see the callout below):

HookWhere it is
[data-loopwise-slot="header-before"]Inside .navbar-wrapper, above the platform navbar. Part of the sticky region: a header inserted here sticks together with ours. When the school's navbar style is the default (not "fixed"), the desktop bar slides up by the platform navbar's own height (64px) on scroll-down, so keep what you insert here within 64px in total — or hide the platform navbar and stay within 64px yourself — if you want it to disappear with the bar. A "fixed" navbar never hides, and neither will your insertion.
[data-loopwise="header"]The platform navbar. Hide it with CSS when your header replaces it.
[data-loopwise-slot="header-after"]Inside .navbar-wrapper, below the platform navbar.
[data-loopwise-slot="footer-before"]Inside .footer-wrapper, above the platform footer.
[data-loopwise="footer"]The platform footer (empty when the school disabled it, or on /@slug).
[data-loopwise-slot="footer-after"]Inside .footer-wrapper, below the platform footer.

Learning centre — the dashboard shell with the learning header and sidebar: /learning (dashboard), /learning/my-courses, /learning/announcements, /learning/assignments and /learning/reservations, including the assignment and reservation detail pages under them. A different shell from the storefront — no sticky navbar, no footer — with the same mechanism under its own names. Lesson players (/learning/<course>/…), assignment submissions (/learning/submissions/…) and the live reservation room (/learning/reservations/<id>/session) render their own full-screen shells and expose no slots:

HookWhere it is
[data-loopwise-slot="learning-header-before"], [data-loopwise-slot="learning-header-after"]Above / below the learning header.
[data-loopwise="learning-header"]The platform learning header.
[data-loopwise-slot="learning-sidebar-before"], [data-loopwise-slot="learning-sidebar-after"]Above / below the sidebar, inside the sidebar column — they are hidden with it on small screens.
[data-loopwise="learning-sidebar"]The platform sidebar.

onReady below is the helper from Lifecycle events; the example does not work without it.

function applyChrome() {
  const slot = document.querySelector(
    '[data-loopwise-slot="header-before"][data-loopwise-live]'
  );
  if (!slot || slot.querySelector('.my-header')) return;
  slot.append(buildHeader()); // your element, with class "my-header"
}
onReady(applyChrome);
document.addEventListener('loopwise:navigate', applyChrome);

data-loopwise-live is present on every slot, data-loopwise part and main#content of the shell currently on screen, and absent on a parked copy (below). It is set after hydration, so it is there by the time loopwise:ready and loopwise:navigate fire; it is not in the server-rendered HTML.

/* Your header replaces ours */
[data-loopwise='header'] {
  display: none;
}

Left-behind shells are parked, not removed. The storefront content pages, the /@slug profiles and the learning centre are three shells. Moving between content pages keeps the same shell, so what you appended stays. Crossing into another shell (/@slug/courses, or into the learning centre) does not tear the old one down: the router keeps it in the DOM, hidden (display: none, out of the accessibility tree), with your nodes still inside, and shows it again — nodes included — when the user navigates back. Up to two left-behind copies are kept per route level and evicted by count, never by time, so right after /@slug/courses there are two .navbar-wrappers and two of every slot, and loopwise:navigate fires while the parked one is still there. A bare document.querySelector('[data-loopwise-slot="header-before"]') may return the hidden one, and "my node exists somewhere in the document" is not proof that the live slot has it. Select the slot with [data-loopwise-live] and look for your node inside that slot, as in the example above.

The height rule still applies: anything you insert inside .navbar-wrapper becomes part of the sticky bar, while the page's top padding and — for non-fixed navbars — the scroll-down hide distance are both sized for the platform navbar alone (64px on desktop).

The storefront sets data-chrome="none" on <body> while a page takes over the full viewport (for example a live meeting room). This hides both .navbar-wrapper and .footer-wrapper via CSS. If your custom CSS injects a header or footer, scope it so it is also hidden in that state:

body[data-chrome='none'] .my-injected-header {
  display: none;
}

Custom CSS is SCSS

Your custom CSS is compiled as SCSS (Sass, SCSS syntax) and served minified. Plain CSS is valid SCSS, so most stylesheets work unchanged, but you also get SCSS features: nesting, variables, & parent references, and mixins. The compiler's compressed output normalises your source — attribute-selector quotes are dropped, so [data-loopwise="header"] is served as [data-loopwise=header] — so compare behaviour, not bytes, when checking what was applied.

Invalid SCSS is rejected on save. When you save, your SCSS is compiled first. If it does not compile, the save fails and the admin shows the exact compiler error — nothing is applied. Saving is the test; there is no need to compile your SCSS elsewhere first. This runs on both the Rails settings form and the Admin GraphQL updateSettings mutation (the update_settings MCP tool calls the same mutation), so the same check applies when you edit custom CSS through the API or MCP.

Two conveniences apply on save: a single wrapping <style>…</style> is unwrapped for you (the same unwrapping also happens when the stylesheet is served), and if a school's already-stored CSS is broken — saved before this validation existed — the appearance-advanced page shows a warning banner and the storefront falls back to the last successfully compiled CSS rather than dropping your styles. That fallback is best-effort: the last-good copy is cached and can expire, so a school whose CSS has never compiled successfully still serves no custom CSS. You can still save unrelated settings without first fixing that legacy CSS.

The @import trap

@import means two different things, and only one of them works:

  • CSS @import — pulling in a remote stylesheet or web font — is fine. Both the url() form and a bare quoted URL survive compilation:

    @import url('https://fonts.googleapis.com/css2?family=Inter&display=swap');
    @import 'https://fonts.googleapis.com/css2?family=Inter&display=swap';
  • Sass @import 'partial' (importing another Sass file) is not supported. There is no file system for your partials to live on, so the compile fails — and, per the rule above, the save is rejected with that error. Put everything in the single field instead.

No size limit

No layer enforces a size limit on custom CSS. Keep it lean anyway: it is fetched and parsed on every storefront page load.

Never inline base64 images

Do not embed images as base64 data URIs — neither background: url(data:image/…) in custom CSS nor <img src="data:image/…"> in markup. A data URI cannot be cached, compressed, or served independently of the resource that carries it: it bloats that stylesheet or page, so the image bytes are re-transferred and re-parsed every time the parent resource is fetched (a cache miss or revalidation) instead of being cached and reused on their own. Inlining many images this way is the most common cause of slow custom storefronts. Base64 also adds ~33% overhead versus the original binary.

Host each image as its own file — upload it in the page builder (新增圖片 / 上傳圖片) or point at any CDN URL — and reference it by URL:

.hero {
  background-image: url('https://cdn.example.com/hero.webp');
}

Referencing by URL lets the browser cache each image on its own and download it in parallel. It also makes markup images eligible for lazy-loading — add loading="lazy" to an <img> to defer offscreen images (CSS background images are not lazy-loaded automatically).

Custom JavaScript

Custom JavaScript is processed in the browser, not on the server. The storefront fetches your script and decides how to run it based on its shape:

  • Plain JavaScript (no <script> tag) is loaded and run as a single program, verbatim — comments and all. It is syntax-checked first; if it does not parse, it is not run.
  • HTML markup with <script> tags is parsed: each <script src="..."> becomes an external script, each inline <script> block is run, and any loose text between blocks is treated as plain JavaScript. Loose code is injected verbatim — comments are not stripped, so // inside string literals and URLs stays intact. Each loose fragment is syntax-checked on its own; a fragment that does not parse is skipped and logs console.warn('Custom script fragment skipped: invalid JavaScript syntax', …) with the parser message, while the other fragments still run.
  • Mixed or partially-invalid content falls back to block-by-block parsing so individual valid blocks still run.

Syntax checking (via new Function) and the try/catch wrapper apply to inline <script> blocks and loose code recovered from the parsed path. They do not apply to:

  • External <script src="..."> — appended as-is; its contents are neither fetched nor validated by the loader, and a load failure is logged, not caught.
  • A whole plain program — loaded verbatim without a try/catch, so a runtime error in it can surface to the page.

Only real JavaScript executes. A <script type="application/ld+json"> block is treated as data and injected verbatim (not validated or wrapped). Any other non-JavaScript type is left alone.

When it runs

The loader runs after hydration (never during initial render). Injection timing then depends on classification:

  • A whole plain program is appended immediately once fetched and validated.
  • Parsed blocks (external, inline, and loose code) are scheduled on the window load event, or via requestIdleCallback if the page is already interactive.

Injected scripts are de-duplicated per version, so the same snippet is not added twice across re-renders — and they are not re-run on client-side navigation. A script runs once per full page load; to react to route changes, listen for the lifecycle events below.

Lifecycle events

The storefront dispatches two CustomEvents on document. They are a published contract, covered by the same test as the selectors above.

EventWhenevent.detail
loopwise:readyOnce per full page load, after the storefront layout and its shell have hydrated: every data-loopwise-slot / data-loopwise hook the page rendered carries data-loopwise-live (a shell that never hydrates releases the event after 5 s). The shell hydrates a little later than the layout around it, and hydration discards nodes appended before it ran, so this is the earliest moment an insertion is safe. On pages that render the platform header and footer, .navbar-wrapper and .footer-wrapper are in the DOM; bare flows (password reset, redirects) have neither and fire immediately.{ pathname }
loopwise:navigateAfter every client-side navigation that changes the pathname, deferred until the new page has committed — the new route's layout and any content that rendered with it are in the DOM. The exact delay is not a contract (it is one animation frame in a visible tab, a zero-delay timer in a hidden one). Content or document.title still streaming behind a loading state can land later. Not fired for query-string-only changes (pagination, filter tabs). If a second navigation lands before the first has fired, only the final destination is announced.{ pathname }

Custom scripts attach asynchronously — they are fetched after the layout has hydrated, so by the time your code runs loopwise:ready has already fired. Check the window.loopwise.ready flag first and only fall back to the event when it is not yet set:

function onReady(fn) {
  if (window.loopwise?.ready) fn();
  else document.addEventListener('loopwise:ready', fn, { once: true });
}

function applyChrome() {
  const slot = document.querySelector(
    '[data-loopwise-slot="header-before"][data-loopwise-live]'
  );
  if (!slot || slot.querySelector('.my-injected-header')) return;
  slot.append(buildHeader()); // your element, with class "my-injected-header"
}

onReady(applyChrome);
document.addEventListener('loopwise:navigate', applyChrome);

Handlers must be idempotent, and "already inserted" must mean inside the live slot. The storefront shell (and its slots) survives navigation between content pages, so an element you inserted is usually still there when the next loopwise:navigate fires — inserting again creates duplicates. But a shell the user just left is parked, hidden, with your nodes still in it, so document.querySelector('#my-header') can find a node that is not on screen and wrongly skip the live slot. Query inside the live slot (liveSlot.querySelector(...), as above), or remove your node and reinsert.

Page content is parked the same way: after /courses/posts, the /courses page — .mc-page, .mc-course-card and all — is still in main#content, hidden, next to the live page, and a third page adds a second parked copy. Pages carry no live marker (there is one main#content per shell, not per page). Filter what you read or change with element.checkVisibility(); offsetParent !== null is not equivalent — it is also null for the live navbar when the school's navbar style is "fixed".

loopwise:ready guarantees the storefront layout has hydrated — including the platform header, footer, slots and main#content on pages that render them (main#content[data-loopwise-live] is the live shell's). Do not insert into a slot before it: React's hydration reconciles the server-rendered shell and removes nodes it did not render. Page content that streams in behind a loading state can still land after it — if you depend on a specific page element, check for it in your handler or observe it with a MutationObserver. Do not read React internals (root fiber, isDehydrated) to detect hydration; they are not a contract and change between React versions.

Caching and propagation

Custom CSS and JavaScript are cached at two layers:

  • Origin — 5 minutes (stale-while-revalidate, stale-if-error).
  • Edge — 300 seconds (max-age=300, s-maxage=300).

The storefront requests them with a ?v=<school updated_at> cache-buster, so a save that bumps the school's timestamp starts serving the new version as caches turn over — typically within a few minutes. No manual step is required; the admin's "revalidate" action does not force an immediate purge, it just relies on these short TTLs.

If a change doesn't appear, wait out the ~5 minute window and hard-refresh. There is no way to force a global purge faster than the TTL.

On this page