Themes can now specify an initialAction in home screen config to set the default selected action when entering home normally. Falls back to this theme setting when no explicit action is requested, but explicit firmware navigation still takes precedence.
27 KiB
SD-card theme creation
CrossPoint ships one built-in base theme, Lyra. Additional themes live on the SD card and are selected from Settings. A downloaded theme is just a folder containing a theme.json and optional assets such as 1-bit BMP icons.
CrossPoint ignores unknown JSON fields. Other readers, such as CrossInk, can add their own fields under a namespaced object like extensions.crossink without breaking CrossPoint.
Folder layout
Manual install paths:
/.themes/<theme-id>/theme.json # hidden folder used by the downloader
/themes/<theme-id>/theme.json # visible folder for manual installs
Hosted theme packages live in the tools repo under:
../crosspoint-tools/public/themes/<theme-id>/theme.json
../crosspoint-tools/public/themes/<theme-id>/icons/*.bmp
Theme ids must be path-safe: letters, numbers, -, and _ only. Spaces are not accepted because ids are used in folder names, URLs, and settings.
Minimal theme
{
"schema": 1,
"id": "my-theme",
"name": "My Theme",
"description": "Short user-facing description shown in the downloader.",
"inherits": "lyra",
"metrics": {
"homeTopPadding": 48,
"menuRowHeight": 42
},
"components": {
"homeMenu": {
"font": "medium",
"style": "regular",
"centeredText": true,
"selectionStyle": "underline",
"showIcons": false
}
},
"devices": {
"x3": {
"constraints": {
"screenWidth": 480,
"screenHeight": 800,
"frontButtons": 4,
"sideButtons": "up-down"
}
},
"x4": {
"constraints": {
"screenWidth": 480,
"screenHeight": 800,
"frontButtons": 0,
"sideButtons": "up-down"
}
}
}
}
Top-level fields:
schema: currently1.id: stable id used for settings, folder name, and downloads.name: display name shown in Settings and the downloader.description: short downloader text.inherits:lyrafor normal SD themes.classicis accepted for manually installed themes that intentionally build from the Classic renderer.metrics: layout numbers shared across screens.components: style rules for themeable UI surfaces.assets.icons: optional icon file map.devices: optional per-device overrides keyed byx3orx4.requires: optional metadata for other tooling. CrossPoint currently ignores it.extensions: optional namespaced metadata for other firmware/apps. CrossPoint currently ignores it.
Device overrides
The active device id is x3 or x4. Any supported field under devices.<device-id> overrides the top-level value:
{
"metrics": {
"homeCoverHeight": 300
},
"components": {
"homeRecents": {
"maxBooks": 3
}
},
"devices": {
"x3": {
"metrics": {
"homeCoverHeight": 280
},
"components": {
"homeRecents": {
"maxBooks": 3
}
}
}
}
}
Use constraints to document intended screen and button assumptions for builders and compatible apps:
"constraints": {
"screenWidth": 480,
"screenHeight": 800,
"frontButtons": 4,
"sideButtons": "up-down"
}
CrossPoint parses these constraints but does not reject themes when they do not match.
Metrics
Metrics tune global spacing and layout. Any omitted metric keeps Lyra's default.
Common home/list metrics:
topPadding: top inset above normal page headers.headerHeight: default header band height for non-home screens.verticalSpacing: default vertical gap between major screen regions.contentSidePadding: left/right inset used by default list and menu renderers.listRowHeight: row height for single-line lists.listWithSubtitleRowHeight: row height for two-line lists such as Recent Books.menuRowHeight: height of one home menu tile. InlauncherGrid, this is used bydrawButtonMenuinside each grid cell; it is not the gap between grid cells.menuSpacing: vertical spacing between items when rendering a plain one-column home menu. It does not affectlauncherGrid, because each grid cell is rendered as a one-item menu.tabSpacing: spacing between tab labels.tabBarHeight: height of the settings tab bar.scrollBarWidth: list scrollbar width.scrollBarRightOffset: list scrollbar inset from the right edge.homeTopPadding: top inset before the home cover/recent-books area in the legacy home renderer.homeCoverHeight: cover image height used by home recents.homeCoverTileHeight: total home recents tile/slot height, including cover title space when applicable.homeRecentBooksCount: number of recent books to request/render on home.homeContinueReadingInMenu: whether Continue Reading is part of the home launcher/menu actions.homeShowContinueReadingHeader: whether the current book title can appear in the home header.homeMenuTopOffset: legacy/manual home menu offset below the cover area. SDscreens.home.layoutthemes should prefer explicit layout slots such ascarouselMenuGap.buttonHintsHeight: bottom button-hint band height.sideButtonHintsWidth: side button-hint band width.
Other supported metric groups:
- Battery:
batteryWidth,batteryHeight,batteryBarHeight - Reader progress/status:
progressBarHeight,progressBarMarginTop,statusBarHorizontalMargin,statusBarVerticalMargin - Keyboard:
keyboardKeyWidth,keyboardKeyHeight,keyboardKeySpacing,keyboardBottomKeyHeight,keyboardBottomKeySpacing,keyboardBottomAligned,keyboardCenteredText,keyboardVerticalOffset,keyboardTextFieldWidthPercent,keyboardWidthPercent,keyboardKeyCornerRadius,keyboardFillUnselected,keyboardOutlineAllUnselected,keyboardDrawSpecialOutlineWhenUnselected,keyboardSecondaryLabelRightPadding,keyboardSecondaryLabelTopPadding,keyboardMinArrowHeadSize - Popups:
popupTopOffsetRatio,popupMarginX,popupMarginY,popupFrameThickness,popupCornerRadius,popupTextBold,popupTextInverted,popupTextBaselineOffsetY,popupProgressBarHeight,popupProgressDrawOutline,popupProgressClampPercent,popupProgressFillInverted,popupProgressOutlineInverted - Text fields:
textFieldHorizontalPadding,textFieldNormalThickness,textFieldCursorThickness,textFieldLineEndOffset
Screen Layouts
Themes can define screens.<screen>.layout to place UI regions with the SDK row/column layout system. This is the preferred path for new SD themes.
Each layout node can contain:
id: slot name used by widgets or firmware renderers.axis:columnstacks children top-to-bottom;rowlays children left-to-right.gap: pixels inserted between this node's direct children.slots: child layout nodes.fixed: exact pixel size along the parent axis.flex: proportional size after fixed children and gaps are subtracted.token: named size frommetrics, such asmenuRow,recents,buttons,header,row,subtitleRow, orgap.
Example:
"screens": {
"home": {
"navigation": "linear",
"layout": {
"axis": "column",
"gap": 0,
"slots": [
{
"id": "header",
"fixed": 40,
"axis": "row",
"gap": 4,
"slots": [
{ "id": "homeClock", "fixed": 52 },
{ "id": "homeTitle", "flex": 1 },
{ "id": "homeBattery", "fixed": 66 }
]
},
{ "id": "recents", "fixed": 340 },
{ "id": "carouselMenuGap", "fixed": 36 },
{ "id": "launchers", "fixed": 192 },
{ "id": "homeSpacer", "flex": 1 },
{ "id": "buttons", "fixed": 40 }
]
}
}
}
Important layout rules:
- A parent layout's
gaponly affects its direct child slots. fixedandflexdecide how much space a slot receives. They do not decide how a widget draws inside that slot.- Widget-specific
gapfields control spacing inside that widget. - Named spacer slots such as
carouselMenuGapandhomeSpacerdo not draw anything unless a widget targets them. They are useful for placing visible regions without manualx/ycoordinates. - If a screen layout is invalid or missing required slots, CrossPoint falls back to the built-in Lyra-safe layout for that screen.
Home navigation modes:
linear: default. Front/side navigation buttons all move through the visible home actions as one ordered list.splitAxis: front left/right move through launcher actions; side up/down move through recent-book actions. Bottom button hints show Left/Right.carousel: front left/right move through recent-book actions; side up/down move through launcher actions. Use this when left/right should stay inside a cover carousel and up/down should enter or leave the launcher menu.
Home initialAction can optionally choose the default selected action when entering home normally:
"initialAction": "reader:recent"
Supported values match launcher action values. Explicit firmware navigation, such as returning to Settings from a settings submenu, still overrides this default.
Layouts vs widgets
Layouts only create named rectangles. They do not choose whether a screen is a list, cover grid, carousel, or any other presentation.
Widgets choose what renders inside those rectangles. This keeps themes explicit and prevents firmware from guessing a grid just because a screen has a list slot.
For screens.recentBooks, use:
- No
recentBooksscreen: use the built-in Lyra recent-books screen. layoutonly, or alistwidget: use the normal themed recent-books list in thelistslot.- A
coverGridwidget: use FreeInkUI's cover-grid component in the target slot.
Minimal themed list example:
"recentBooks": {
"layout": {
"axis": "column",
"gap": 8,
"slots": [
{ "id": "header", "fixed": 48 },
{ "id": "list", "flex": 1 },
{ "id": "buttons", "fixed": 40 }
]
},
"widgets": [
{ "slot": "list", "type": "list" }
]
}
Cover-grid screen example:
"recentBooks": {
"layout": {
"axis": "column",
"gap": 16,
"slots": [
{ "id": "header", "fixed": 48 },
{ "id": "list", "flex": 1 },
{ "id": "buttons", "fixed": 40 }
]
},
"widgets": [
{
"slot": "list",
"type": "coverGrid",
"columns": 3,
"rowGap": 36,
"coverWidth": 92,
"coverHeight": 132,
"rowHeight": 172,
"labelLines": 2,
"selectionStyle": "coverFrame"
}
]
}
Do not use screen-level coverGrid. Cover-grid settings belong on a widget with type: "coverGrid".
Home widgets
Home layouts use screens.home.widgets to map slot rectangles to visible content.
Supported widget types:
clock: draws the clock when the device has RTC support. On devices without clock support, the slot stays empty.headerTitle: draws the normal home/header title.battery: draws the battery indicator.recents: draws the configured home recents component.recentCoverGrid: draws recent books with FreeInkUI'scoverGridcomponent.launcherList: draws actions as one vertical menu inside its slot.launcherGrid: draws actions in a row/column grid inside its slot.buttonHints: draws bottom button hints.
launcherGrid fields:
slot: slot id to render into.presentation: optional presentation style. UseiconTabsfor icon-only launcher tabs with outlined unselected cells and filled selected cells.columns: number of grid columns.rows: optional fixed row count. If omitted, rows are derived from visible launcher count and columns.gap: pixels between grid cells, both horizontally and vertically.items: launcher actions. Each item acceptstext,icon, andaction.
All home widgets also support visual placement fields:
layer: draw order. Lower layers draw first; higher layers paint on top. Widgets with the same layer keep JSON order.offsetX: moves the widget right after layout. Negative values move left.offsetY: moves the widget down after layout. Negative values move up.bleed: expands the widget draw rectangle outside its slot without changing layout. Use either a single number or{ "top": 0, "right": 0, "bottom": 0, "left": 0 }.inset: shrinks the widget draw rectangle inside its slot without changing layout. Use either a single number or{ "top": 0, "right": 0, "bottom": 0, "left": 0 }.
Example overlap:
{
"slot": "recents",
"type": "recents",
"layer": 0,
"bleed": { "bottom": 24 }
},
{
"slot": "launchers",
"type": "launcherGrid",
"layer": 10,
"offsetY": -12,
"columns": 2,
"gap": 24
}
That keeps the structural row/column layout intact, but lets the launcher grid visually overlap the recents area by 12 pixels.
buttonHints widget fields:
labels.confirmlabels.previouslabels.nextlabels.back
Button-hint labels are localized semantic tokens, not literal UI strings. Supported tokens are default, empty, back, home, select, confirm, open, toggle, up, down, left, and right. default uses the firmware fallback for that navigation mode; empty renders no label for that button.
Example carousel hints:
{
"slot": "buttons",
"type": "buttonHints",
"labels": {
"confirm": "select",
"previous": "left",
"next": "right"
}
}
When components.buttonHints.layout is shapes or icons, these same localized tokens render as button shapes/icons where supported.
Example icon tabs:
{
"slot": "tabs",
"type": "launcherGrid",
"presentation": "iconTabs",
"columns": 5,
"rows": 1,
"gap": 6,
"iconSize": 32,
"selectedRadius": 5,
"items": [
{ "icon": "folder", "action": "activity:fileBrowser" },
{ "icon": "recent", "action": "activity:recentBooks" },
{ "icon": "library", "action": "activity:opds" }
]
}
Launcher actions:
activity:fileBrowseractivity:recentBooksactivity:opdsactivity:fileTransferactivity:settingsactivity:reader
For launcherGrid, the final cell height is:
(slot height - gap * (rows - 1)) / rows
Then each cell calls the themed home menu renderer with one item. That means:
- Increase the widget
gapto create more visible space between grid items. - Increase the launcher slot
fixedheight if larger gaps need more total room. - Use
menuRowHeightto tune the selectable tile/text/icon band inside each cell. - Do not expect
menuSpacingto changelauncherGridspacing.
For a 3-row launcher grid with menuRowHeight: 48 and gap: 24, use a launcher slot near:
3 * 48 + 2 * 24 = 192
recentCoverGrid / recent-books coverGrid widget fields:
slot: slot id to render into.columns: grid columns.rows: grid rows.gap: horizontal pixels between cells. Also used vertically whenrowGapis omitted.rowGap: vertical pixels between cover-grid rows.cellInset: optional padding inside each cover-grid cell, before the cover and label are drawn.labelInset: optional padding inside the title label area. Use{ "left": 5, "right": 5 }to keep two-line titles away from cell edges.coverWidth: rendered cover width.coverHeight: rendered cover height and thumbnail size to generate.placeholderIconSize: maximum icon size for the missing-cover placeholder.rowHeight: height of each cell row, including label space.labelHeight: title label area below each cover. Use0to hide titles.labelGap: vertical pixels between the cover and title label block.labelLines: maximum title lines to render. IncreaserowHeightwhen this is greater than1.selectionStyle:fill,outline,coverFrame, ornone. PrefercoverFramefor cover grids because it frames only the thumbnail and does not depend on title wrapping.startIndex: first recent-book index to show. Use2when a featured area already uses the first two books.
These cover-grid widgets use FreeInkUI's coverGrid for layout, labels, cell styling, and selected state. CrossPoint supplies a cover painter callback so SD-card thumbnails render from the existing recent-book cache.
Cover widgets can use different visual coverWidth and coverHeight values on different screens. CrossPoint still generates and reads one largest-needed thumbnail height for the active theme, then scales/crops it into each widget. That keeps the same book cover available on home and recent-books instead of requiring separate BMPs per widget.
featuredBookCard fields:
coverWidth,coverHeight: rendered cover size and thumbnail height to generate.placeholderIconSize: maximum icon size for the missing-cover placeholder.coverGap: horizontal gap between the cover and title/author text.titleGap: vertical gap below the Continue Reading label before the book card starts.startIndex: recent-book index to show.
Components
Fonts
Most components accept:
"font": "large",
"style": "bold"
Supported font values are small, medium, and large.
Semantic aliases are also accepted:
chrome,caption: same assmall.body,label: same asmedium.title,display: same aslarge.
Supported style values are regular and bold.
Home recents
components.homeRecents controls the home cover area.
Supported types:
default: Lyra default.none: no cover area.cover-strip: one or more cover slots.
Example:
"homeRecents": {
"type": "cover-strip",
"maxBooks": 3,
"wrap": true,
"selectionLineWidth": 3,
"inactiveSelectionLineWidth": 1,
"selectionCornerRadius": 6,
"slots": [
{
"book": "previous",
"x": "padding",
"y": "center",
"height": 210,
"widthPercent": 62
},
{
"book": "selected",
"x": "center",
"y": "top",
"height": 280,
"widthPercent": 62,
"selected": true,
"title": {
"enabled": true,
"font": "large",
"style": "bold",
"maxLines": 2,
"offsetY": 12
}
},
{
"book": "next",
"x": "right-padding",
"y": "center",
"height": 210,
"widthPercent": 62
}
]
}
Slot fields:
book:selected,previous,next, orindex.bookIndex: zero-based index whenbookisindex.x:padding,center, orright-padding.y:toporcenter.height: requested thumbnail height. CrossPoint generates/cache-misses thumbnails at requested sizes.widthPercent: cover width as a percent of the slot height.xOffset,yOffset: positional adjustments.selected: whether this slot receives the active selection outline.title: optional book title under the cover.
CrossPoint currently reads up to five cover slots.
Cover slots with selected: true draw after unselected slots, so selected covers appear in front. Within each group, slots draw in the same order they appear in JSON. For a carousel where the side covers sit behind the middle cover, mark the middle slot as selected: true.
Use xOffset and yOffset for small relative adjustments after x/y placement has been resolved:
- Positive
xOffsetmoves a cover right. - Negative
xOffsetmoves a cover left. - Positive
yOffsetmoves a cover down. - Negative
yOffsetmoves a cover up.
Example carousel layering:
"slots": [
{
"book": "previous",
"x": "padding",
"y": "center",
"height": 225,
"widthPercent": 62,
"xOffset": 32
},
{
"book": "next",
"x": "right-padding",
"y": "center",
"height": 225,
"widthPercent": 62,
"xOffset": -32
},
{
"book": "selected",
"x": "center",
"y": "top",
"height": 300,
"widthPercent": 62,
"selected": true
}
]
In that example, the side covers are pushed toward the center, and the selected cover is drawn in the foreground.
Home menu
components.homeMenu styles the home menu options.
Supported fields:
font,style,boldcenteredTextcenterVerticallyshowIconspanelWidthdrawPanelpanelCornerRadiusselectionStyle:fill,outline,triangle,underline, orpillselectionCornerRadiusselectionInsetselectedTextInvertedselectionFillBlackrowPaddingXtextInsetX
Lists
components.list styles Settings, Browse, Recent Books, and similar list rows.
Supported fields:
font,style,boldsubtitleFontIdvalueFontIdshowIconsiconSizetextGapselectionStyle:fill,outline, orunderlineselectionCornerRadiusselectionFillselectionOutlineselectedTextInvertedrowBackgroundscenterSingleLineRowssubtitleRowAutoHeightcenterValueVerticallyrowSidePaddingrowGaptextInsetXselectionInsetXselectionInsetYtitleOffsetYsubtitleOffsetYsubtitleTopPaddingsubtitleBottomPaddingsubtitleInterLineGapvalueOffsetYsubtitleValueOffsetYiconOffsetY
Header
components.header styles page headers.
Supported fields:
font,style,boldcenteredTitleshowDividertitleOffsetYbatteryOffsetY
Tab bar
components.tabBar styles tabs.
Supported fields:
font,style,boldequalWidthselectionStyle:fillorunderlineselectedCornerRadiusselectedTextInverteddrawDividerhorizontalInset
Button hints
components.buttonHints styles bottom and side button hints.
Supported fields:
font,style,boldlayout:buttons,groups,shapes, oriconsbuttonWidthsmallButtonHeightcornerRadiusfilloutlinedrawEmptyshapessidePaddinggroupGapbottomMargininnerPaddingshapeSizetextOffsetY
Use layout: "shapes" or layout: "icons" for icon-only arrows/circle/square hints.
Reader chrome
screens.reader.chrome styles the reader status lane. Reader chrome still uses screens.reader.layout slots for placement; the chrome object controls how those slots draw.
Battery fields:
style:iconorbar.width: battery glyph width in pixels.height: battery glyph height in pixels.offsetY: vertical adjustment applied after the battery is positioned in its slot. Positive values move it down; negative values move it up.track: background/track style for bar batteries:none,hairline,outline, ordither.fill: fill style for bar batteries:solid,dither, orsegments.direction: fill direction:left-to-right,right-to-left,center-out,bottom-to-top, ortop-to-bottom.orientation:horizontalorvertical. Vertical is also implied bybottom-to-topandtop-to-bottom.caps:squareorpixel.pixeltrims the four filled corners for a softer e-ink cap.segments: number of filled blocks whenfillissegments.segmentGap: pixels between segments.radius: rounded-rect radius for bar track/fill/segments. Keep this small for thin e-ink bars;0is square.showPercentage: whether reader chrome may draw the battery percentage when the global setting allows it.
Example:
"screens": {
"reader": {
"layout": {
"axis": "row",
"gap": 8,
"slots": [
{ "id": "bookmark", "fixed": 18 },
{ "id": "battery", "fixed": 38 },
{ "id": "title", "flex": 1 },
{ "id": "clock", "fixed": 42 },
{ "id": "progress", "fixed": 82 }
]
},
"chrome": {
"battery": {
"style": "bar",
"width": 38,
"height": 3,
"offsetY": 1,
"track": "none",
"fill": "solid",
"direction": "left-to-right",
"radius": 0,
"showPercentage": false
}
}
}
}
Icons
Icons are optional. If both homeMenu.showIcons and list.showIcons are false, omit assets.icons and the icon files to reduce download size and heap use.
Supported icon keys:
folder,folder24text,text24image,image24book,book24file,file24recentsettings,settings2transferlibrarywifihotspotbookmark
Generate firmware-matching 1-bit BMP icons:
python3 scripts/generate-theme-icons.py \
--icons src/components/icons \
--themes ../crosspoint-tools/public/themes
The script writes rotated BMP files into each ../crosspoint-tools/public/themes/<theme-id>/icons/ folder.
Reference them from theme.json:
"assets": {
"icons": {
"folder": "icons/folder.bmp",
"book": "icons/book.bmp",
"settings": "icons/settings2.bmp"
}
}
CrossInk and extension fields
CrossPoint only consumes the fields documented above. Unknown fields are ignored, so theme authors can include extra data for compatible apps and firmware.
Put app-specific fields under extensions.<namespace>:
{
"schema": 1,
"id": "crossink-stats",
"name": "CrossInk Stats",
"inherits": "lyra",
"components": {
"homeRecents": {
"type": "cover-strip",
"maxBooks": 1
}
},
"extensions": {
"crossink": {
"schema": 1,
"readingStats": {
"enabled": true,
"placement": "home-footer",
"font": "small",
"style": "regular",
"show": [
"currentStreak",
"readingTime",
"pagesRead",
"percentComplete"
],
"labels": {
"currentStreak": "streak",
"readingTime": "reading",
"pagesRead": "pages"
}
}
}
}
}
Recommended extension rules:
- Keep CrossPoint layout fields in
metrics,components,assets, anddevices. - Keep CrossInk-only fields under
extensions.crossink. - Add an extension-local
schemawhen the app-specific format may evolve. - Prefer declarative fields such as
placement,font,show, andlabelsover code-like strings. - Keep extension data compact. CrossPoint ignores it, but it is still parsed transiently when discovering themes.
- Do not put required CrossPoint behavior only in an extension field. CrossPoint will not read it.
CrossInk can also use requires for compatibility metadata:
"requires": {
"crosspoint": {
"schema": 1,
"modules": ["cover-strip"]
},
"crossink": {
"schema": 1,
"modules": ["reading-stats"]
}
}
CrossPoint currently treats requires as metadata.
Package manifest
After adding or changing hosted themes, regenerate themes.json in crosspoint-tools:
python3 scripts/generate-theme-manifest.py \
--root ../crosspoint-tools/public/themes \
--base-url http://crosspointreader.com/themes \
--output ../crosspoint-tools/public/themes/themes.json
The manifest generator:
- scans every
../crosspoint-tools/public/themes/<theme-id>/theme.json - includes every file in each theme folder
- writes per-file
sizeandcrc32 - writes the theme
id,name,description, andtotalSize
Commit the theme files and the regenerated manifest together in crosspoint-tools.
Validation checklist
Before publishing:
for f in ../crosspoint-tools/public/themes/themes.json ../crosspoint-tools/public/themes/*/theme.json; do
python3 -m json.tool "$f" >/dev/null
done
python3 scripts/generate-theme-manifest.py \
--root ../crosspoint-tools/public/themes \
--base-url http://crosspointreader.com/themes \
--output ../crosspoint-tools/public/themes/themes.json
pio run -e gh_release
On device:
- Download the theme from Settings -> UI Theme -> Download Themes.
- Exit the downloader and let the device silently restart to clear WiFi/TLS heap.
- Return to Settings -> UI Theme and select the downloaded theme.
- Check Home, Settings, Browse, Recent Books, button hints, tabs, popups, keyboard, and reader menus.