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.
| Selector | What it is |
|---|---|
.navbar-wrapper | The sticky wrapper around the storefront navbar. To insert a header, use the slots inside it (below) rather than the wrapper's own children. |
.footer-wrapper | The 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#content | The main content landmark (<main id="content">) that wraps every page body between the navbar and footer. |
.setting-banner | The 200px page banner on settings-style content pages. |
.purchased-card | The 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-pagination | Region 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-title | Two 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):
| Hook | Where 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:
| Hook | Where 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).
Hiding the navbar and footer
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 theurl()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 logsconsole.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
loadevent, or viarequestIdleCallbackif 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.
| Event | When | event.detail |
|---|---|---|
loopwise:ready | Once 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:navigate | After 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.
Managing OAuth Applications
How to create, configure, and manage OAuth applications in Loopwise.
Loopwise Pages
How Loopwise Pages deploys work — the deployment lifecycle, preview surfaces, why assets redirect to *.loopwise.page, how a page's route wins over built-in routes, propagation timing, and the real size limits.