Creating a Style for eXeLearning¶
Minimum Required Elements of a Style¶
A style must include at least the following elements:
| Element | Description |
|---|---|
config.xml |
Main configuration file. |
| CSS files | Visual styling of the content. |
| JS files | JavaScript functionality for the style (optional). |
screenshot.png |
Preview image (screenshot). |
icons/ |
Folder containing iDevice icons. |
The config.xml File¶
Example structure:
<?xml version="1.0"?>
<theme>
<name>example</name>
<title>Example</title>
<version>2025</version>
<compatibility>3.0</compatibility>
<author>eXeLearning.net</author>
<license>Creative Commons by-sa</license>
<license-url>http://creativecommons.org/licenses/by-sa/3.0/</license-url>
<description>Example style for eXe.
iDevice icons by…</description>
</theme>
File Fields¶
name: Internal name (ID) and folder name of the style (no spaces or special characters).title: Name displayed in the style selector in eXeLearning.version: Version number of the style.compatibility: eXeLearning version the style is compatible with.author,license,license-url: Author and licensing information.description: Style description (may include line breaks).
CSS Files¶
- Placed in the root folder of the style.
- You may include one or multiple files (
style.cssis required). - If multiple files exist, they are loaded in alphabetical order.
JavaScript (JS) Files¶
- Placed in the root folder of the style.
- You may include multiple JS files (most styles use a single
style.js); they are also loaded alphabetically. - JavaScript does not run inside eXeLearning, it only runs after exporting the content.
Screenshot (screenshot.png)¶
- Required name:
screenshot.png. - Location: root folder.
- Recommended size: 1200×550 px.
icons/ Folder¶
- Contains images for iDevice icons.
- Supported formats:
.gif,.png,.jpg,.svg.
The shared icon set¶
Every bundled style ships the same 50 icons under the same names — activity,
agreement, alert, … video. Only the artwork and the file format differ: base and
neo ship .png, the others .svg.
That is a contract, not a coincidence. A block stores its icon by base name, without the
extension, and each style resolves that name to whichever file it happens to ship
(setThemeIconFiles() in src/shared/export/renderers/IdeviceRenderer.ts, falling back to
<name>.png). So an author can switch from one style to another and every block keeps its
icon — no re-picking — as long as both styles use the same names. The names also mean the
same thing everywhere: each icon symbolises the same idea in every style, and only the
drawing changes.
Copy the names from public/files/perm/themes/base/base/icons/ when you build your own
style. A name only your style has does work, but it turns into a missing icon the moment the
author switches style.
Renaming an icon a release already shipped¶
Don't. A project stores the base name its author picked, and that name lives on in .elp
files you will never see again, so renaming the file makes those blocks resolve to nothing.
When a name has to change anyway — a misspelling, or an outlier that breaks the shared set —
the old name has to keep working. Add it to RENAMED_THEME_ICONS in
src/shared/block-icon.ts, and to its JS twins in public/app/common/blockIconRuntime.js and
public/app/workarea/project/idevices/content/blockNode.js:
export const RENAMED_THEME_ICONS = {
objetives: 'objectives', // neo, misspelt from v4.0.0 to v4.0.3
'think-alt': 'think_alt', // educablue, hyphenated; only in v4.0.4 pre-release projects
};
Every point that turns a stored name into a file goes through it: deriveBlockIcon() for a
legacy iconName, resolveIconName() in the export renderer for a stored descriptor,
normalizeIconDescriptor() / resolveThemeIconData() in the workarea, and
normalizeBlockIcon() in public/app/yjs/YjsProjectBridge.js for a descriptor arriving from
a collaborator or replayed by undo/redo. Entries are added,
never removed — an entry is the document format's memory of a name it once wrote, and only a
project migration that rewrites stored icon names on open could retire one.
The exception: universal¶
universal deliberately does not use the shared set. It ships 85 icons, all .svg, from
a collection aligned with the three principles of Universal Design for Learning (UDL),
and colours the whole block from the icon's file name:
| Prefix | UDL principle | Accent |
|---|---|---|
udl_eng_* |
Engagement | green (#418211) |
udl_rep_* |
Representation | purple (#9c4fc0) |
udl_exp_* |
Action & Expression | blue (#0065ab) |
udl_sup_* |
— | none; the same subjects again, left in the style's default box colours |
The colouring is done in style.css with :has() on the file name, so choosing the icon is
what selects the block's palette:
.exe-export .box:has(.box-icon img[src*="eng_"]) .box-head {
background: #f4fcf0;
}
Because those names are its own, switching between universal and any other style is the one
case where the author does have to choose the icons again.
Block Icons: Style Icons and General Icons¶
The icon picker offers two groups. Style icons are the artwork your icons/
folder ships. General icons are Google Material Symbols, bundled with the
application and shared by every style.
A General icon is not an <img>. It is a <span class="exe-material-icon"> whose
glyph is applied as a CSS mask and painted with currentColor:
<span class="exe-material-icon" style="--exe-material-icon-url:url('data:image/svg+xml;utf8,…');"></span>
Your style does not have to do anything for these icons to work. The application
ships a default rule — content/css/base.css in exports, the workarea stylesheet in
the editor — that sizes the span to 40×40 and applies the mask. It loads before
theme/style.css, so you only override what you want to change.
The export wrapper is
<div class="exe-content exe-export …">— both classes on the same element. A.exe-contentrule is therefore live in exports too, and an.exe-exportrule of equal specificity does not replace it, it just wins property by property. Do not restate the size in the.exe-exportrule unless you mean to change it there.
Matching them to your own artwork¶
Set --exe-icon-color so General icons take the same colour as your Style icons.
Without it the two surfaces diverge, and neither is likely to match your artwork: in the
content the glyphs inherit whatever colour the block header text happens to have, and in
the icon picker they fall back to a neutral grey (see Which colour the icon picker
actually uses, below):
.exe-content {
--exe-icon-color: #d86e41;
}
.exe-content .box-head .box-icon {
color: var(--exe-icon-color);
}
If that colour is light — because your block header has a coloured background — set
--exe-icon-picker-color as well. The icon picker paints its chips on a light
background, so a white --exe-icon-color would leave both the icons and the selected
state invisible there. The picker prefers this variable when it is present:
.exe-content {
--exe-icon-color: #fff; /* white icon on the coloured block header */
--exe-icon-picker-color: #0d77d1; /* readable on the picker's light chips */
}
The variable reaches the General icons, because they are painted with currentColor.
It cannot reach your Style icons: those are <img> artwork, and only a filter can
recolour an image. If your artwork is a flat light colour, brightness(0) flattens it
to black and an invert/sepia/saturate/hue-rotate chain takes it to the accent, so both
groups match in the picker. The educablue style is a worked example:
.modal #change-block-icon-modal-content .option-block-icon.exe-icon img {
filter: brightness(0) saturate(100%) invert(50%) sepia(40%) saturate(1164%)
hue-rotate(171deg) brightness(77%) contrast(119%);
}
To change the size, redefine the span. Keep transform: scale(1.2): Material Symbols
are drawn inside a 20px live area on a 24px grid, so without it the glyph renders
about 17% smaller than edge-to-edge Style icons.
Note that the individual scale property composes with transform rather than
replacing it: a rule setting scale: 0.9 and another setting transform: scale(1.2)
leave the element at 0.9 × 1.2. If your style already uses scale, account for it.
.exe-content .box-icon .exe-material-icon {
width: 50px;
height: 50px;
transform: scale(1.2);
}
Two things that will break your icons¶
- Dark mode by global inversion. If your style does
html.exe-dark-mode { filter: invert(…) }and then counter-inverts the media with:is(img, video, iframe), name the span too — it is not an<img>, so otherwise your General icons invert while your Style icons do not. Only do this when your Style icons are light artwork; if--exe-icon-coloris already a dark colour meant to invert to a light one, leave the span out of that list. - Filtering the icon picker. If your style restyles
#change-block-icon-modal-content .option-block-icon, never put the rule on the chip itself — it has a background, so afilterthere paints a solid block over the icon. Scope it to theimginside, and leave the General icons out: they are inline<svg>filled withcurrentColor, so--exe-icon-picker-coloralready reaches them and filtering them too fights it.
Which colour the icon picker actually uses¶
No style is named anywhere in the application. There is no table of per-style colours in the core: the picker tint comes from CSS your style declares, and a style you write yourself is read exactly like the seven that ship with eXeLearning. Whatever the bundled styles achieve, yours can achieve with the same declarations — and nothing you leave out is filled in for you by name.
The picker resolves the accent in JavaScript (getCurrentThemeIconColor() in
public/app/workarea/project/idevices/content/blockNode.js). It reads the block header —
both variables are custom properties, so declaring them once on .exe-content is enough —
and takes the first value it finds:
--exe-icon-picker-color → --exe-icon-color
currentColor works here too: the resolver turns it into the block header's own text
colour before handing it to the picker, which sits outside .exe-content and would
otherwise read the keyword against the modal's near-black text.
Those two are the whole resolver, and both are yours. --icon-primary is not an input:
it is the application chrome's own accent (the top menu, the node tree), and overriding it
on .exe-content does not reach the picker.
When your style declares neither variable, the picker is left untouched and its own
stylesheet decides. What follows the tint is the chip ink, the chip border on the selected
and hovered states, and the custom-icon button — the rules in
assets/styles/components/_modals.scss that read
var(--modal-icon-color, var(--modal-icon-default)), with
--modal-icon-default: var(--text) declared on :root in
assets/styles/abstracts/_variables.scss.
⚠️ Not everything in the picker follows your tint. The section headings
(.icon-options-section-title) use --icon-gray, and the chip background and border use
--main-background-color and --gray-border-light. Setting --exe-icon-color recolours
the glyphs and the selection, not the whole dialog.
⚠️ A style that declares neither --exe-icon-picker-color nor --exe-icon-color gets
a neutral grey, whatever its own palette is. That grey is deliberate — it says "no tint
was chosen" instead of showing a colour you did not pick — but it will still sit next to
your Style icons in the same picker: pink artwork beside grey glyphs. Declare
--exe-icon-color, and sample it from your icons/ artwork rather than guessing from the
palette; the two are often not the same colour.
Checklist: owning your block icons from CSS alone¶
Everything below lives in your style.css. Nothing needs a change in the application.
| Goal | Declare |
|---|---|
| General icons match your Style artwork | --exe-icon-color on .exe-content |
| Block header needs a light icon on a coloured band | keep --exe-icon-color light, add --exe-icon-picker-color with a tint readable on the picker's light chips |
| Paint the header icon itself | .exe-content .box-head .box-icon { color: var(--exe-icon-color); } |
Recolour your Style <img> artwork inside the picker |
a filter chain on .modal #change-block-icon-modal-content .option-block-icon.exe-icon img — never on the chip, and never on the General icons |
| Resize the icons in exports | .exe-content .box-icon img and .exe-content .box-icon .exe-material-icon |
| Resize them in the editor | the same pair under #node-content-container.exe-content .box-head .exe-icon … |
| Multi-hued Style artwork that no single tint can match | pick one tint and declare it anyway — neo and universal do exactly this; changing your mind later is a one-line edit in your own stylesheet |
The one thing CSS cannot do for you is tint an <img> without a filter, which is why the
Style/General split exists at all. Everything else about the presentation of both groups —
colour, size, scale, dark mode, picker appearance — is yours.
The editor is a second surface, with a stronger selector¶
The editor content pane is <section id="node-content-container" class="exe-content …"> —
both on one element, so every .exe-content rule is already live in the editor. But the
workarea stylesheet writes #node-content-container.exe-content .box-head …, which outranks
any plain .exe-content rule. Where the editor disagrees with the preview, that is almost
always why: match the application's own selector to override it (equal specificity is enough,
since the style loads last).
universal is the worked example — it sizes the icons once for the export and once for the
editor, and keeps the <img> and the span in step in both:
/* Export */
.exe-content .box-icon img {
width: 50px;
height: auto;
scale: 0.9;
}
.exe-content .box-icon .exe-material-icon {
width: 50px;
height: 50px;
scale: 0.9;
/* …mask declarations… */
}
/* Editor */
#node-content-container.exe-content .box-head .exe-icon img {
height: 45px;
scale: 1;
}
#node-content-container.exe-content .box-head .exe-icon .exe-material-icon {
width: 45px;
height: 45px;
scale: 1;
/* …mask declarations… */
}
Note what it does not do: it never restates transform: scale(1.2). The default rule
already carries it, and scale: 0.9 composes with it, so the span lands at 0.9 × 1.2 while
the <img> lands at 0.9 — which is exactly the 24/20 correction the glyph needs.
mask-image needs a server¶
General icons are painted with mask-image, which does not work over file://. An
export opened straight from disk loses them while the <img> Style icons survive. Test
exports over http://, not by double-clicking index.html.
Reference styles for the icons¶
base and universal (in public/files/perm/themes/base/) are the two styles kept in step
with the application. base declares --exe-icon-color: #d86e41 and paints
.exe-content .box-head .box-icon with it, then sizes the span once under .exe-export;
universal adds the editor rules above and a variable-based dark mode. base ships no
dark mode, which makes it the cleaner reference for everything else.
Optional Files and Folders¶
You can add other useful folders such as:
fonts/→ Fonts (.woff,.woff2, etc.)img/→ Additional images. If this folder containsfavicon.pngorfavicon.ico, exports will use it instead of the default eXeLearning favicon.
Example usage in CSS:
#siteNav a {
background: #191748 url(img/example.svg) no-repeat 8px center;
padding-left: 42px;
}
CSS and Exported Content¶
All exported content is wrapped in a <div class="exe-content">.
Using this class ensures your CSS does not interfere with the eXeLearning interface.
Incorrect:
h2 { color: red !important; }
Correct:
.exe-content h2 { color: red !important; }
Effects (FX)¶
The Accordion, Tabs, Pagination, Carousel and Timeline effects are styled by
exe_effects.css, which ships with eXeLearning. Every effect is wrapped in
<div class="exe-fx exe-...">.
Their controls (tab labels, page numbers, carousel arrows, accordion titles and the
timeline markers) are links, but not links inside a block of text. exe_effects.css
therefore keeps them free of underlines and gives them a focus ring of its own, so a
keyboard user always sees where the focus is, whatever style is applied.
The ring is drawn with outline, outside the control, over the page background. To
recolour it, set one custom property rather than restyling every control:
.exe-content {
--exe-fx-focus-color: #054d4d; /* default: #1a1a1a */
}
Keep at least a 3:1 contrast ratio between that colour and the page background. Paint
the controls themselves with background or box-shadow — the ring never uses
box-shadow, so both keep working together.
To recolour the controls, target .exe-content .fx-tabs a and
.exe-content .fx-pagination a: the carousel pagination carries the fx-pagination
class too, so it needs no rule of its own.
Careful with the current page, which is a filled chip: exe_effects.css gives it a
dark background and white text, and a rule that recolours every pagination link is more
specific than that pair, so it repaints the text and leaves it on the dark chip. Either
exclude it or restyle the whole chip:
/* Leave the chip alone... */
.exe-content .fx-pagination li:not(.fx-current) a {
color: #b14900;
}
/* ...or give it both halves of the pair */
.exe-content .fx-pagination .fx-current a {
background: #145cb1;
color: #ffffff;
}
What the sheet owns, and what your style should not restate¶
exe_effects.css now owns two things for every FX control, in every export format:
- No underline in any state — the controls are not links inside prose (WCAG 1.4.1).
- The focus ring on
:focus-visible, drawn withoutlineand tinted by--exe-fx-focus-color.
So a style written before this does not need its own :focus-visible outlines for
.fx-tabs a, .fx-pagination a or .fx-carousel-pagination a, nor its
text-decoration: none workarounds: one --exe-fx-focus-color replaces the lot.
Two things are now defects rather than harmless leftovers:
outline: none/outline: 0on anything matching.fx-*,.exe-accordion,.exe-tabs,.exe-paginated,.exe-carouselor.exe-timeline. It suppresses the ring the sheet guarantees, and the keyboard user loses the focus indicator.overflow: hiddenoroverflow: autoon an FX container. The ring is drawn outside the control, so a clipping ancestor cuts it off.exe_effects.cssdroppedoverflowfrom.fx-tabsand.fx-carousel-pagination(a clearfix:afterdoes the job instead) and reopens the accordion while a title has focus:.js .exe-accordion:has(.fx-accordion-title:focus-visible){overflow:visible}
Keep the underline on .fx-timeline-minor h3 a. Those are plain text links inside the
event list, not controls, and the sheet keeps them underlined on purpose.
#efefef is the surface that binds your link colour¶
exe_effects.css paints #efefef in five places: the current tab's label, the tab
panel, every pagination chip, the paginated page panel and the carousel panel. Three of
those hold author prose — links included — and the chips are links themselves. So
.exe-content a lands on #efefef in any project that uses an effect, and that surface,
not white, is what your link colour has to clear: 4.5:1 on #efefef is appreciably
darker than 4.5:1 on white.
Do not buy back a brighter link with a rule scoped to the tabs. It is not one special case
but five components and the prose inside three of them, so the exception would end up wider
than the rule. Measure on #efefef and pick one colour. The base style is the worked
example — #b14900 on the controls, #973f00 on hover/focus.
The carousel pagination is not a separate component¶
Its list carries both fx-carousel-pagination and fx-pagination, and the duplicated
link rules were removed from exe_effects.css accordingly. A third selector for
.fx-carousel-pagination a alongside .fx-pagination a is therefore duplication.
.fx-carousel-pagination on its own is not dead: it still positions the prev/next
arrows and sets their font size. Do not delete rules that use it for layout.
Reference styles for the effects¶
base and universal are also the two styles kept up to date with this sheet; read their
style.css before writing your own FX rules. They show the two correct shapes for the
current chip — base excludes it, universal restyles it whole:
/* base */
.exe-content .fx-tabs a,
.exe-content .fx-pagination li:not(.fx-current) a {
color: #b14900;
}
.exe-content {
--exe-fx-focus-color: #054d4d;
}
/* universal */
.exe-content .fx-tabs a,
.exe-content .fx-pagination a {
color: #145cb1;
}
.exe-content .fx-pagination .fx-current a {
background: #145cb1;
color: #ffffff;
}
.exe-content {
--exe-fx-focus-color: #0d70c7;
}
CSS Classes by Export Type¶
Each export type adds a CSS class to the <body> element:
| Export Type | Body Class |
|---|---|
| Website | exe-web-site |
| SCORM | exe-scorm |
| EPUB | exe-epub |
| IMS | exe-ims |
| Single HTML page | exe-single-page |
All export formats also include the general class exe-export.
Example:
<body class="exe-export exe-web-site">
Empty Site Footer¶
Every page ends with <footer id="siteFooter">, which holds the license and the
content of the Page footer project property. When a project has neither — no
license to display (empty, propietary or not appropriate) and a Page footer
that is empty or whitespace only — the footer is rendered with an extra class:
<footer id="siteFooter" class="siteFooter-empty"><div id="siteFooterContent"></div></footer>
content/css/base.css hides those footers, so a theme that gives #siteFooter a
background, border or padding does not show a stray empty bar.
The element stays in the DOM on purpose: a theme that wants to keep showing the footer area even when it is empty (a decorative bottom band, for instance) can opt out with a rule of equal or higher specificity:
#siteFooter.siteFooter-empty {
display: block;
}
JavaScript in Styles¶
You can use jQuery (already included in exported content).
Common functionality found in built-in eXe styles:
- Toggle menu visibility.
- Remember menu open/closed state between pages.
- Show/hide the search bar.
- Teacher mode visibility. Content marked teacher only is hidden by default in
exports. The self-serve toggle button is opt-in: it only appears when the page is opened
with
?exe-teacher=1(alias?teacher-mode=1, or the legacy?exe-teacher-toggler=1). The toggle is OFF by default — the viewer activates it to add themode-teacherclass to<html>and reveal teacher content (its state is remembered inlocalStorage). Without the parameter there is no toggle and teacher content stays hidden. eXeLearning's own preview loads with?exe-teacher=1, so the toggle is available there. See Teacher Mode in embedding.md.// Themes can still trigger the (opt-in) toggle setup explicitly: $exeExport.teacherMode.init();
Rewriting URLs: use $exeExport.setUrlParam¶
A style that carries its own state across navigation — nav=false for a collapsed menu is
the usual one — has to add and remove a query parameter on links the application
generated. Never split the string yourself. libs/exe_export.js, which every export format
loads, provides:
$exeExport.setUrlParam(href, name, value)
It sets one parameter, keeps every other parameter and the fragment, and removes the
parameter when value is null. A pure fragment (#ancla) is returned untouched, because
adding a query there would turn an in-page jump into a page load. The value is used as
given, so encode it yourself when it needs it (the export's own search does
encodeURIComponent(this.query) for q).
Why it matters: those URLs are a shared channel. Through them travel exe-teacher /
teacher-mode, the search term q, print=1, and the xAPI launch parameters endpoint,
auth, actor and registration. Wiping the query breaks LMS tracking and teacher mode,
and nothing on screen says so. The three broken patterns all survive a casual test, because
sample content usually has no other parameter:
e.href = ref.split('?')[0]; // drops every parameter AND the fragment
e.href = ref + (cond ? '&' : '?') + 'nav=false'; // guesses the separator; a second '?'
window.location = this.href + '?nav=false'; // lands inside the fragment if there is a #ancla
Two more rules that go with it:
- Operate on
getAttribute('href'), not the.hrefproperty, which absolutises the relative links the export writes. - Read the current value with
URLSearchParams, not by searching the string:indexOf('nav=false')also matches?xnav=falseand any fragment containing that text.
base is the reference implementation — detection in init(), and one params() helper
that both adds and removes:
if (new URLSearchParams(window.location.search).get('nav') === 'false') {
$('body').addClass('siteNav-off');
myTheme.params('add');
}
// Toggle nav=false keeping the rest of the URL using a common function.
params: function (act) {
var value = act == 'add' ? 'false' : null;
$('.nav-buttons a').each(function () {
this.setAttribute(
'href',
$exeExport.setUrlParam(this.getAttribute('href'), 'nav', value)
);
});
},
Final Recommendations¶
- Export in different formats: SCORM, Web, Single HTML Page to test compatibility.
- Adjust your CSS and JS so the style works consistently across all export types.
- Package the style into a
.zipfile: - The
.zipfile name must match the<name>inconfig.xml. - The
.zipmust not contain extra parent folders — all files (config.xml, CSS, JS,icons/, etc.) must be in the root.
How to Create a New Style Easily¶
- Download any of the styles included in eXeLearning. Choose the one that most closely resembles what you want to achieve.
- Unzip the
.zipfolder. - Edit the
config.xmlfile to modify all the information you need. - Follow the steps described in the "Final Recommendations" section to complete the creation of your style.
Theme Types¶
eXeLearning has three types of themes:
| Type | Source | Storage | Served By |
|---|---|---|---|
| Base | Built-in with eXeLearning | Server /perm/themes/base/ |
Server |
| Site | Admin-installed for all users | Server /perm/themes/site/ |
Server |
| User | Imported by user or from .elpx | Client IndexedDB + Yjs | Never server |
Deployment Information¶
Base themes (built-in)¶
The styles included by default in eXeLearning are located in:
/public/files/perm/themes/base/
These are synchronized at server startup and cannot be modified by users.
After editing a base style, run
bun run bundle:resources. The editor's preview loads themes from pre-built ZIP bundles underpublic/bundles/themes/, not from the source folder, so until you rebuild them the preview keeps showing the previous CSS even though the server serves the new file.
Site themes (admin-installed)¶
Administrators can install themes for all users by placing them in:
/perm/themes/site/
Site themes can be: - Activated/deactivated by the administrator - Set as the default theme for new projects
Using custom styles with Docker¶
To bind a custom style directly in docker-compose.yml, add the following volume:
volumes:
- ./my-theme:/mnt/data/perm/themes/base/my-theme:ro
Where ./my-theme is the directory on your host machine containing the style.
This makes the style available to all users.
This is required because eXeLearning recreates the entire /base/ themes directory when restarting the server. Any style not bound as a volume would be overwritten during this process.
User Styles (Client-Side)¶
Important: User themes are NEVER stored or served by the server.
User styles are imported through the application interface (Styles → Imported) and stored entirely on the client side.
Storage locations¶
IndexedDB (browser, per-user)
└── user-themes store: key = "userId:themeName"
└── Each user's themes are isolated by userId prefix
└── Switching users shows only that user's themes
Yjs themeFiles (project document)
└── Currently selected user theme (for collaboration/export)
.elpx export
└── Embedded theme files (for portability)
Per-user isolation: When user "alice" logs in, she only sees her themes. If "bob" logs in on the same browser, he sees his own themes, not Alice's. This is achieved by storing themes with a composite key userId:themeName in IndexedDB.
How user themes work¶
- Import: User uploads ZIP → Stored in IndexedDB (local browser storage)
- Select: User selects theme → Copied to Yjs
themeFiles(for collaboration/export) - Change: User selects different theme → Removed from Yjs (but kept in IndexedDB)
- Export: If user theme is selected → Embedded in .elpx ZIP
- Open: Another user opens .elpx → Theme extracted to their IndexedDB
Admin configuration¶
# Allow users to import/install styles
ONLINE_THEMES_INSTALL=1 # 1 = enabled (default), 0 = disabled
When disabled (ONLINE_THEMES_INSTALL=0):
- Users cannot import external themes via the interface
- Users cannot open .elpx files with embedded themes
Why user themes are client-side¶
This design follows the same pattern as other user-specific data (like favorite iDevices):
- Per-user storage: Each user's themes are private to them
- No server storage: Themes don't consume server disk space
- Collaboration via Yjs: Selected theme is shared with collaborators in real-time
- Portability: Themes embedded in .elpx can be opened anywhere
- Offline capability: Themes work without server connectivity