Add SD theme system

Adds installable SD-card themes with manifest downloads, theme registry parsing, themed home/chrome/settings/file browser support, FreeInk layout integration, theme documentation, and layout tests.
This commit is contained in:
Justin Mitchell
2026-06-28 01:44:38 -04:00
parent cbaa498ccc
commit 48aa3c8e02
69 changed files with 6352 additions and 358 deletions
+881
View File
@@ -0,0 +1,881 @@
# 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:
```text
/.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:
```text
../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
```json
{
"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`: currently `1`.
- `id`: stable id used for settings, folder name, and downloads.
- `name`: display name shown in Settings and the downloader.
- `description`: short downloader text.
- `inherits`: `lyra` for normal SD themes. `classic` is 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 by `x3` or `x4`.
- `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:
```json
{
"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:
```json
"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. In `launcherGrid`, this is used by `drawButtonMenu` inside 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 affect `launcherGrid`, 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. SD `screens.home.layout` themes should prefer explicit layout slots such as `carouselMenuGap`.
- `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`: `column` stacks children top-to-bottom; `row` lays 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 from `metrics`, such as `menuRow`, `recents`, `buttons`, `header`, `row`, `subtitleRow`, or `gap`.
Example:
```json
"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 `gap` only affects its direct child slots.
- `fixed` and `flex` decide how much space a slot receives. They do not decide how a widget draws inside that slot.
- Widget-specific `gap` fields control spacing inside that widget.
- Named spacer slots such as `carouselMenuGap` and `homeSpacer` do not draw anything unless a widget targets them. They are useful for placing visible regions without manual `x`/`y` coordinates.
- 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.
### 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 `recentBooks` screen: use the built-in Lyra recent-books screen.
- `layout` only, or a `list` widget: use the normal themed recent-books list in the `list` slot.
- A `coverGrid` widget: use FreeInkUI's cover-grid component in the target slot.
Minimal themed list example:
```json
"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:
```json
"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's `coverGrid` component.
- `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. Use `iconTabs` for 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 accepts `text`, `icon`, and `action`.
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:
```json
{
"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.confirm`
- `labels.previous`
- `labels.next`
- `labels.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:
```json
{
"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:
```json
{
"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:fileBrowser`
- `activity:recentBooks`
- `activity:opds`
- `activity:fileTransfer`
- `activity:settings`
- `activity:reader`
For `launcherGrid`, the final cell height is:
```text
(slot height - gap * (rows - 1)) / rows
```
Then each cell calls the themed home menu renderer with one item. That means:
- Increase the widget `gap` to create more visible space between grid items.
- Increase the launcher slot `fixed` height if larger gaps need more total room.
- Use `menuRowHeight` to tune the selectable tile/text/icon band inside each cell.
- Do not expect `menuSpacing` to change `launcherGrid` spacing.
For a 3-row launcher grid with `menuRowHeight: 48` and `gap: 24`, use a launcher slot near:
```text
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 when `rowGap` is 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. Use `0` to hide titles.
- `labelGap`: vertical pixels between the cover and title label block.
- `labelLines`: maximum title lines to render. Increase `rowHeight` when this is greater than `1`.
- `selectionStyle`: `fill`, `outline`, `coverFrame`, or `none`. Prefer `coverFrame` for cover grids because it frames only the thumbnail and does not depend on title wrapping.
- `startIndex`: first recent-book index to show. Use `2` when 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:
```json
"font": "large",
"style": "bold"
```
Supported `font` values are `small`, `medium`, and `large`.
Semantic aliases are also accepted:
- `chrome`, `caption`: same as `small`.
- `body`, `label`: same as `medium`.
- `title`, `display`: same as `large`.
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:
```json
"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`, or `index`.
- `bookIndex`: zero-based index when `book` is `index`.
- `x`: `padding`, `center`, or `right-padding`.
- `y`: `top` or `center`.
- `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 `xOffset` moves a cover right.
- Negative `xOffset` moves a cover left.
- Positive `yOffset` moves a cover down.
- Negative `yOffset` moves a cover up.
Example carousel layering:
```json
"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`, `bold`
- `centeredText`
- `centerVertically`
- `showIcons`
- `panelWidth`
- `drawPanel`
- `panelCornerRadius`
- `selectionStyle`: `fill`, `outline`, `triangle`, `underline`, or `pill`
- `selectionCornerRadius`
- `selectionInset`
- `selectedTextInverted`
- `selectionFillBlack`
- `rowPaddingX`
- `textInsetX`
### Lists
`components.list` styles Settings, Browse, Recent Books, and similar list rows.
Supported fields:
- `font`, `style`, `bold`
- `subtitleFontId`
- `valueFontId`
- `showIcons`
- `iconSize`
- `textGap`
- `selectionStyle`: `fill`, `outline`, or `underline`
- `selectionCornerRadius`
- `selectionFill`
- `selectionOutline`
- `selectedTextInverted`
- `rowBackgrounds`
- `centerSingleLineRows`
- `subtitleRowAutoHeight`
- `centerValueVertically`
- `rowSidePadding`
- `rowGap`
- `textInsetX`
- `selectionInsetX`
- `selectionInsetY`
- `titleOffsetY`
- `subtitleOffsetY`
- `subtitleTopPadding`
- `subtitleBottomPadding`
- `subtitleInterLineGap`
- `valueOffsetY`
- `subtitleValueOffsetY`
- `iconOffsetY`
### Header
`components.header` styles page headers.
Supported fields:
- `font`, `style`, `bold`
- `centeredTitle`
- `showDivider`
- `titleOffsetY`
- `batteryOffsetY`
### Tab bar
`components.tabBar` styles tabs.
Supported fields:
- `font`, `style`, `bold`
- `equalWidth`
- `selectionStyle`: `fill` or `underline`
- `selectedCornerRadius`
- `selectedTextInverted`
- `drawDivider`
- `horizontalInset`
### Button hints
`components.buttonHints` styles bottom and side button hints.
Supported fields:
- `font`, `style`, `bold`
- `layout`: `buttons`, `groups`, `shapes`, or `icons`
- `buttonWidth`
- `smallButtonHeight`
- `cornerRadius`
- `fill`
- `outline`
- `drawEmpty`
- `shapes`
- `sidePadding`
- `groupGap`
- `bottomMargin`
- `innerPadding`
- `shapeSize`
- `textOffsetY`
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`: `icon` or `bar`.
- `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`, or `dither`.
- `fill`: fill style for bar batteries: `solid`, `dither`, or `segments`.
- `direction`: fill direction: `left-to-right`, `right-to-left`, `center-out`, `bottom-to-top`, or `top-to-bottom`.
- `orientation`: `horizontal` or `vertical`. Vertical is also implied by `bottom-to-top` and `top-to-bottom`.
- `caps`: `square` or `pixel`. `pixel` trims the four filled corners for a softer e-ink cap.
- `segments`: number of filled blocks when `fill` is `segments`.
- `segmentGap`: pixels between segments.
- `radius`: rounded-rect radius for bar track/fill/segments. Keep this small for thin e-ink bars; `0` is square.
- `showPercentage`: whether reader chrome may draw the battery percentage when the global setting allows it.
Example:
```json
"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`, `folder24`
- `text`, `text24`
- `image`, `image24`
- `book`, `book24`
- `file`, `file24`
- `recent`
- `settings`, `settings2`
- `transfer`
- `library`
- `wifi`
- `hotspot`
- `bookmark`
Generate firmware-matching 1-bit BMP icons:
```bash
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`:
```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>`:
```json
{
"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`, and `devices`.
- Keep CrossInk-only fields under `extensions.crossink`.
- Add an extension-local `schema` when the app-specific format may evolve.
- Prefer declarative fields such as `placement`, `font`, `show`, and `labels` over 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:
```json
"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`:
```bash
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 `size` and `crc32`
- writes the theme `id`, `name`, `description`, and `totalSize`
Commit the theme files and the regenerated manifest together in `crosspoint-tools`.
## Validation checklist
Before publishing:
```bash
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:
1. Download the theme from Settings -> UI Theme -> Download Themes.
2. Exit the downloader and let the device silently restart to clear WiFi/TLS heap.
3. Return to Settings -> UI Theme and select the downloaded theme.
4. Check Home, Settings, Browse, Recent Books, button hints, tabs, popups, keyboard, and reader menus.