Files
Crosspoint/docs/theme-creation.md
Justin Mitchell 79f5657fb2 Add configurable default home screen action
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.
2026-06-28 02:38:53 -04:00

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: 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:

{
  "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. 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:

"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.

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 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:

"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'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:

{
  "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:

{
  "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:fileBrowser
  • activity:recentBooks
  • activity:opds
  • activity:fileTransfer
  • activity:settings
  • activity: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 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:

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:

"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:

"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:

"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:

"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:

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, 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:

"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 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:

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.