diff --git a/README.md b/README.md index 1422ca4..7390d23 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ A standalone, independently buildable reference for writing an **external module** for the [Speculum smart-mirror dashboard](https://github.com/pierrejochem/SpeculumSmartMirror). +πŸ“– **[Developer guide](https://pierrejochem.github.io/SpeculumExampleModule/)** β€” quickstart, module anatomy, and declaring settings. + It builds to a thin JAR that the Speculum app discovers at runtime β€” no app rebuild, no source checkout of the mirror required. It demonstrates the full module API: config reading, the `start()` / `refresh()` / `stop()` lifecycle, @@ -25,7 +27,8 @@ last notification it received. `URLClassLoader`, and finds factories via the JDK `ServiceLoader`. - The factory declares its config options through `settingsSchema()`, so the Speculum admin console renders real controls for them. See - [Module settings](#module-settings). + [Module settings](#module-settings), or the guide's + [Settings Schema](https://pierrejochem.github.io/SpeculumExampleModule/settings-schema.html) page. ## Requirements @@ -131,6 +134,18 @@ The method has a default empty implementation, so a module that declares nothing still works and simply keeps the raw key/value rows. Any key saved in a config but absent from the schema also falls back to a raw row. +## Documentation + +| Page | Covers | +| --- | --- | +| [Developer guide](https://pierrejochem.github.io/SpeculumExampleModule/) | Overview and how the host discovers a plugin. | +| [Quickstart](https://pierrejochem.github.io/SpeculumExampleModule/quickstart.html) | Run the released JAR, or build and deploy from source. | +| [Module Anatomy](https://pierrejochem.github.io/SpeculumExampleModule/module-anatomy.html) | Every file in the project and what the host does with it. | +| [Settings Schema](https://pierrejochem.github.io/SpeculumExampleModule/settings-schema.html) | Declaring options the admin console renders as typed controls. | +| [mirror-api Reference](https://pierrejochem.github.io/SpeculumSmartMirror/api-reference.html) | The published API this module compiles against. | + +The guide is published from [`docs/`](docs/) via GitHub Pages. + ## Bumping the API version `mirror-api` is released on every `v*` tag of the Speculum project; the version diff --git a/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css new file mode 100644 index 0000000..f1a3f97 --- /dev/null +++ b/docs/assets/css/style.css @@ -0,0 +1,254 @@ +/* Speculum docs β€” "magic mirror" aesthetic: pure black, bright white, three brightness tiers */ +:root { + --bg: #000000; + --surface: #0a0a0b; + --surface-2: #111113; + --border: #1f1f23; + --border-2: #2a2a30; + --bright: #ffffff; /* MirrorColors.Bright */ + --normal: #b9b9bd; /* slightly lifted from #999 for body legibility */ + --dimmed: #6c6c74; /* MirrorColors.Dimmed */ + --accent: #7c5cff; /* Kotlin-inspired violet */ + --accent-2: #45c4ff; + --glow: rgba(124, 92, 255, 0.35); + --mono: "SFMono-Regular", ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace; + --sans: "Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --maxw: 1080px; +} + +* { box-sizing: border-box; } + +html { scroll-behavior: smooth; -webkit-text-size-adjust: 100%; } + +body { + margin: 0; + background: var(--bg); + color: var(--normal); + font-family: var(--sans); + font-size: 17px; + line-height: 1.7; + letter-spacing: 0.1px; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* faint radial vignette β€” the "reflection" */ +body::before { + content: ""; + position: fixed; + inset: 0; + background: + radial-gradient(900px 500px at 50% -10%, rgba(124,92,255,0.10), transparent 70%), + radial-gradient(700px 500px at 100% 0%, rgba(69,196,255,0.06), transparent 60%); + pointer-events: none; + z-index: 0; +} + +a { color: var(--accent-2); text-decoration: none; } +a:hover { color: var(--bright); text-decoration: underline; text-underline-offset: 3px; } + +h1, h2, h3, h4 { color: var(--bright); line-height: 1.2; font-weight: 700; letter-spacing: -0.02em; } +h1 { font-size: clamp(2rem, 5vw, 3.2rem); margin: 0 0 0.4em; } +h2 { font-size: 1.7rem; margin: 2.4em 0 0.6em; padding-top: 0.4em; } +h3 { font-size: 1.2rem; margin: 1.8em 0 0.4em; color: var(--bright); } +h2 .anchor, h3 .anchor { color: var(--dimmed); font-weight: 400; margin-left: .4rem; opacity: 0; transition: opacity .15s; } +h2:hover .anchor, h3:hover .anchor { opacity: 1; } + +p { margin: 0 0 1.1em; } + +code { + font-family: var(--mono); + font-size: 0.86em; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 5px; + padding: 0.12em 0.42em; + color: #e6e1ff; +} + +pre { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.1rem 1.25rem; + overflow-x: auto; + margin: 0 0 1.4em; + position: relative; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.02); +} +pre code { background: none; border: 0; padding: 0; color: #d7d7df; font-size: 0.84rem; line-height: 1.6; } + +/* ---------- top nav ---------- */ +header.nav { + position: sticky; top: 0; z-index: 100; + background: rgba(0,0,0,0.72); + backdrop-filter: saturate(160%) blur(14px); + -webkit-backdrop-filter: saturate(160%) blur(14px); + border-bottom: 1px solid var(--border); +} +.nav-inner { + max-width: var(--maxw); margin: 0 auto; + display: flex; align-items: center; gap: 1.5rem; + padding: 0.7rem 1.4rem; +} +.brand { display: flex; align-items: center; gap: 0.6rem; font-weight: 700; color: var(--bright); letter-spacing: -0.01em; } +.brand img { height: 30px; width: auto; display: block; } +.brand:hover { text-decoration: none; } +.nav-links { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-left: auto; align-items: center; } +.nav-links a { + color: var(--normal); font-size: 0.93rem; font-weight: 500; + padding: 0.4rem 0.7rem; border-radius: 8px; white-space: nowrap; +} +.nav-links a:hover { color: var(--bright); background: var(--surface-2); text-decoration: none; } +.nav-links a.active { color: var(--bright); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border-2); } +.nav-links a.gh { color: var(--dimmed); } + +/* dropdown groups (
/) */ +.nav-group { position: relative; } +.nav-group > summary { + list-style: none; cursor: pointer; + color: var(--normal); font-size: 0.93rem; font-weight: 500; + padding: 0.4rem 0.7rem; border-radius: 8px; white-space: nowrap; + display: inline-flex; align-items: center; gap: 0.3rem; +} +.nav-group > summary::-webkit-details-marker { display: none; } +.nav-group > summary::after { content: "β–Ύ"; font-size: 0.7em; opacity: 0.7; } +.nav-group > summary:hover { color: var(--bright); background: var(--surface-2); } +.nav-group.active > summary { color: var(--bright); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border-2); } +.nav-menu { + display: none; position: absolute; top: 100%; right: 0; margin-top: 0.4rem; + flex-direction: column; gap: 0.1rem; min-width: 12rem; + background: rgba(0,0,0,0.96); border: 1px solid var(--border-2); border-radius: 10px; + padding: 0.35rem; box-shadow: 0 12px 30px rgba(0,0,0,0.45); +} +.nav-group:hover .nav-menu, .nav-group[open] .nav-menu { display: flex; } +.nav-menu a { + color: var(--normal); font-size: 0.93rem; font-weight: 500; + padding: 0.45rem 0.7rem; border-radius: 7px; white-space: nowrap; +} +.nav-menu a:hover { color: var(--bright); background: var(--surface-2); text-decoration: none; } +.nav-menu a.active { color: var(--bright); background: var(--surface-2); box-shadow: inset 0 0 0 1px var(--border-2); } + +.menu-toggle { display: none; background: none; border: 1px solid var(--border-2); color: var(--bright); + border-radius: 8px; padding: 0.35rem 0.6rem; font-size: 1.1rem; cursor: pointer; } + +/* ---------- layout ---------- */ +main { position: relative; z-index: 1; max-width: var(--maxw); margin: 0 auto; padding: 2.6rem 1.4rem 5rem; } +.lead { font-size: 1.18rem; color: var(--normal); max-width: 60ch; } + +/* ---------- hero ---------- */ +.hero { text-align: center; padding: 3.5rem 0 2rem; } +.hero img.logo { width: min(440px, 80%); height: auto; filter: drop-shadow(0 0 40px var(--glow)); margin-bottom: 1.2rem; } +.hero .lead { margin: 0 auto 1.8rem; } +.eyebrow { text-transform: uppercase; letter-spacing: 0.28em; font-size: 0.72rem; color: var(--accent-2); font-weight: 600; margin-bottom: 1rem; } + +/* ---------- buttons ---------- */ +.btn-row { display: flex; gap: 0.8rem; justify-content: center; flex-wrap: wrap; margin-top: 1.4rem; } +.btn { + display: inline-flex; align-items: center; gap: 0.5rem; + padding: 0.7rem 1.3rem; border-radius: 10px; font-weight: 600; font-size: 0.96rem; + border: 1px solid var(--border-2); color: var(--bright); background: var(--surface-2); + transition: transform .12s ease, box-shadow .2s ease, border-color .2s; +} +.btn:hover { text-decoration: none; transform: translateY(-2px); border-color: var(--accent); } +.btn.primary { background: linear-gradient(180deg, #8e72ff, #6d4cff); border-color: transparent; box-shadow: 0 8px 30px var(--glow); } +.btn.primary:hover { box-shadow: 0 10px 40px var(--glow); } + +/* ---------- cards ---------- */ +.grid { display: grid; gap: 1rem; margin: 1.6rem 0; } +.grid.cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid.cols-3 { grid-template-columns: repeat(3, 1fr); } +.card { + background: var(--surface); border: 1px solid var(--border); border-radius: 14px; + padding: 1.25rem 1.3rem; transition: border-color .2s, transform .15s, background .2s; +} +.card:hover { border-color: var(--border-2); transform: translateY(-2px); background: var(--surface-2); } +.card h3 { margin: 0 0 0.3rem; font-size: 1.05rem; } +.card p { margin: 0; color: var(--normal); font-size: 0.95rem; } +.card .ic { font-size: 1.4rem; display: block; margin-bottom: 0.5rem; } +.card a.more { display: inline-block; margin-top: 0.7rem; font-size: 0.9rem; font-weight: 600; } + +/* ---------- tables ---------- */ +.table-wrap { overflow-x: auto; margin: 0 0 1.4em; border: 1px solid var(--border); border-radius: 12px; } +table { border-collapse: collapse; width: 100%; font-size: 0.92rem; } +th, td { text-align: left; padding: 0.7rem 0.95rem; border-bottom: 1px solid var(--border); vertical-align: top; } +thead th { background: var(--surface-2); color: var(--bright); font-weight: 600; white-space: nowrap; } +tbody tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: var(--surface); } +td code, th code { font-size: 0.85em; } + +/* ---------- callouts ---------- */ +.note, .warn { + border-radius: 12px; padding: 0.9rem 1.1rem; margin: 0 0 1.4em; + border: 1px solid var(--border-2); background: var(--surface); + font-size: 0.95rem; +} +.note { border-left: 3px solid var(--accent-2); } +.warn { border-left: 3px solid #ffb454; } +.note strong, .warn strong { color: var(--bright); } + +/* ---------- misc ---------- */ +hr { border: 0; border-top: 1px solid var(--border); margin: 3rem 0; } +ul, ol { padding-left: 1.3rem; } +li { margin: 0.3em 0; } +li::marker { color: var(--dimmed); } +.kicker { color: var(--dimmed); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.15em; } +.center { text-align: center; } +.muted { color: var(--dimmed); } +.shot { width: 100%; border-radius: 14px; border: 1px solid var(--border); display: block; } +figure { margin: 0 0 1.4em; } +figcaption { color: var(--dimmed); font-size: 0.85rem; text-align: center; margin-top: 0.5rem; } + +.pill { display: inline-block; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.05em; + padding: 0.2rem 0.55rem; border-radius: 999px; border: 1px solid var(--border-2); color: var(--normal); } +.pill.ok { color: #7ee787; border-color: #1f5128; } +.pill.auth { color: #ffb454; border-color: #5a3f1a; } + +.toc { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.3rem; margin: 0 0 2rem; } +.toc strong { color: var(--bright); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.12em; } +.toc ul { columns: 2; margin: 0.5rem 0 0; } +.toc a { color: var(--normal); } + +/* ---------- footer ---------- */ +footer.site { + position: relative; z-index: 1; border-top: 1px solid var(--border); + padding: 2.5rem 1.4rem; margin-top: 2rem; +} +.footer-inner { max-width: var(--maxw); margin: 0 auto; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; justify-content: space-between; color: var(--dimmed); font-size: 0.9rem; } +.footer-inner a { color: var(--normal); } +.footer-links { display: flex; gap: 1.2rem; flex-wrap: wrap; } + +/* ---------- responsive ---------- */ +@media (max-width: 820px) { + .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } + .toc ul { columns: 1; } + .menu-toggle { display: inline-block; margin-left: auto; } + .nav-links { + display: none; position: absolute; top: 100%; left: 0; right: 0; + flex-direction: column; align-items: stretch; gap: 0.1rem; + background: rgba(0,0,0,0.96); border-bottom: 1px solid var(--border); + padding: 0.6rem 1rem 1rem; margin: 0; + } + .nav-links.open { display: flex; } + .nav-links a { padding: 0.7rem 0.8rem; } + .nav-group { width: 100%; } + .nav-group > summary { padding: 0.7rem 0.8rem; } + .nav-menu { + position: static; min-width: 0; margin: 0; padding: 0 0 0 0.9rem; + border: 0; box-shadow: none; background: none; + } + .nav-group:hover .nav-menu { display: none; } + .nav-group[open] .nav-menu { display: flex; } +} +/* Visually hidden, but still read by screen readers and search engines. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/docs/assets/img/icon.svg b/docs/assets/img/icon.svg new file mode 100644 index 0000000..c38176d --- /dev/null +++ b/docs/assets/img/icon.svg @@ -0,0 +1,20 @@ + +Speculum icon +A diamond looking-glass mark with a central reflection seam and violet-to-teal glass fill. + + + + + + + + + + + + + + + + + diff --git a/docs/assets/img/social.png b/docs/assets/img/social.png new file mode 100644 index 0000000..c243dfd Binary files /dev/null and b/docs/assets/img/social.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..83741aa --- /dev/null +++ b/docs/index.html @@ -0,0 +1,141 @@ + + + + + +Speculum Example Module β€” write a Speculum plugin in one thin JAR + + + + + + + + + + + + + + + + + + + + + + + +
+

Developer guide

+

Write a Speculum plugin in one thin JAR

+

A standalone, independently buildable reference module for the + Speculum smart-mirror dashboard. No app rebuild, no source checkout of the mirror β€” drop a JAR in plugins/ and it appears on screen.

+ + + +

What this is

+

Speculum loads external modules from JARs at runtime. This repository is the smallest complete example of one: a Gradle project with a single module that renders a greeting, a refresh counter and the last notification it received, placed at top_center.

+

It exercises the whole plugin surface, so you can copy it and delete what you do not need:

+
+
+

Runtime discovery

+

A ModuleFactory declared in META-INF/services, found by the JDK ServiceLoader β€” no registration in the host app.

+
+
+

Lifecycle

+

start(), a periodic refresh(), stop(), plus the notification bus and a Compose Content().

+
+
+

Typed settings

+

settingsSchema() tells the admin console what options exist, so they render as real controls instead of raw text rows.

+
+
+

Thin JAR

+

compileOnly against mirror-api and Compose: the built JAR holds only your classes, around 11 KB.

+
+
+ +

How the host finds it

+
    +
  1. You build a JAR and put it in the Speculum install's plugins/ folder.
  2. +
  3. On startup the app scans plugins/*.jar and loads each one in a URLClassLoader whose parent is the app, so Compose and mirror-api come from the host.
  4. +
  5. It asks the JDK ServiceLoader for every org.speculum.core.ModuleFactory on that classloader.
  6. +
  7. Each factory contributes defaultConfig(), so the module is placed and configured without anyone editing config.json.
  8. +
+
Why compileOnly: the API and Compose are provided by the host at runtime through the parent classloader. Bundling them would bloat the JAR and risk loading two copies of the same classes.
+ +

Where to go next

+
+ + + + + + + + +
PageWhat it covers
QuickstartRun the prebuilt JAR, or build from source and deploy it into a mirror.
Module AnatomyEvery file in the project and what the host does with it.
Settings SchemaDeclaring options so the admin console renders typed controls.
mirror-api ReferenceThe full published API: MirrorModule, ModuleConfig, Region, SettingSpec.
+
+
+ + + + diff --git a/docs/module-anatomy.html b/docs/module-anatomy.html new file mode 100644 index 0000000..d6f2bbe --- /dev/null +++ b/docs/module-anatomy.html @@ -0,0 +1,173 @@ + + + + + +Module Anatomy β€” Speculum Example Module + + + + + + + + + + + + + + + + + + + + + + +
+

Module Anatomy

+

Every file, and what the host does with it

+

A Speculum plugin is small enough to read in one sitting. This walks the whole project in the order the host touches it.

+ + + +

Project layout

+
src/main/kotlin/org/speculum/modules/example/
+  ExampleModule.kt          the module: lifecycle + Compose UI
+  ExampleModuleFactory.kt   the SPI entry point
+src/main/resources/META-INF/services/
+  org.speculum.core.ModuleFactory   one line: the factory's FQN
+src/test/kotlin/…              unit + Compose UI tests
+build.gradle.kts            compileOnly deps, deployToMirror task
+gradle/libs.versions.toml   mirror-api and toolchain versions
+ +

The ServiceLoader manifest

+

One line, and the single thing that makes the JAR discoverable:

+
# src/main/resources/META-INF/services/org.speculum.core.ModuleFactory
+org.speculum.modules.example.ExampleModuleFactory
+
Easy to get wrong: the file name must be the fully-qualified interface name, and the contents the fully-qualified implementation name. A typo in either produces no error β€” the module is silently never loaded.
+ +

ModuleFactory

+

The SPI the host instantiates. It names the module, builds instances, and supplies the defaults that let the module appear without anyone editing config:

+
class ExampleModuleFactory : ModuleFactory {
+    override val name: String = "example"
+
+    override fun create(config: ModuleConfig): MirrorModule = ExampleModule(config)
+
+    override fun defaultConfig(): ModuleConfig =
+        ModuleConfig(
+            module = "example",
+            position = "top_center",
+            refreshIntervalMs = 3000,
+            config = mapOf("greeting" to "Loaded from JAR!", "tickStep" to "2"),
+        )
+}
+
+ + + + + + + + + +
MemberPurpose
nameThe module key in config that this factory answers to.
create(config)Builds one instance per config entry. Called once at boot.
defaultConfig()Appended automatically when no entry names this module. Return null to require an explicit entry.
settingsSchema()Declares the options for the admin console. See Settings Schema.
orderStacking order inside a region; lower sits higher. JARs load in no particular order, so this keeps layout deterministic.
+
+ +

MirrorModule lifecycle

+

Read your options once in the constructor, from the ModuleConfig you were handed. The typed accessors fall back when a key is absent:

+
class ExampleModule(config: ModuleConfig) : MirrorModule(config) {
+    private val greeting = config.string("greeting", "Hello, Speculum!")
+    private val tickStep = config.int("tickStep", 1)
+
+    override val refreshIntervalMs: Long = config.refreshIntervalMs.coerceAtLeast(1000)
+}
+

Then three hooks, called by the engine:

+
+ + + + + + + +
HookWhenUse it for
start(scope)Once at boot, with the engine's CoroutineScope.Long-lived background work. The scope is cancelled for you on shutdown.
refresh()Every refreshIntervalMs, as a suspend function.Fetching data. Keep it cancellable; never block the thread.
stop()Once on shutdown.Releasing anything the scope does not own.
+
+
Clamp your interval. The example uses coerceAtLeast(1000) so a misconfigured refreshInterval of 0 cannot spin the refresh loop.
+ +

Rendering with Compose

+

State is ordinary Compose state; writing to it from refresh() recomposes the UI:

+
private var ticks by mutableStateOf(0)
+
+override suspend fun refresh() { ticks += tickStep }
+
+@Composable
+override fun Content() {
+    Column {
+        Text(greeting, color = Color.White, fontSize = 28.sp)
+        Text("Refreshes: $ticks", color = MirrorColors.Normal, fontSize = 18.sp)
+    }
+}
+

Use MirrorColors rather than hand-picked greys. A mirror is a display behind two-way glass, so the palette is tuned for that: Normal, Dimmed and friends stay legible through the coating.

+ +

The notification bus

+

Modules receive broadcasts from the engine and from each other. Override the hook and keep it cheap β€” it runs on the caller's thread:

+
override fun onNotification(notification: Notification) {
+    lastEvent = notification.name
+}
+ +

Gradle: staying thin

+

Everything the host provides is compileOnly, which is what keeps the JAR at roughly 11 KB:

+
dependencies {
+    compileOnly(libs.mirror.api)
+    compileOnly(libs.compose.runtime)
+    compileOnly(libs.compose.foundation)
+    compileOnly(libs.compose.material3)
+    compileOnly(libs.kotlinx.coroutines.core)
+
+    // Tests need the real artifacts, since compileOnly does not reach them.
+    testImplementation(libs.mirror.api)
+    testImplementation(libs.compose.runtime)
+}
+
The test gotcha: compileOnly dependencies are not on the test compile classpath. Every one you need in tests has to be repeated as testImplementation.
+

The deployToMirror task copies the built JAR into a mirror's plugins/ folder; see Quickstart.

+
+ + + + diff --git a/docs/quickstart.html b/docs/quickstart.html new file mode 100644 index 0000000..4c59013 --- /dev/null +++ b/docs/quickstart.html @@ -0,0 +1,113 @@ + + + + + +Quickstart β€” Speculum Example Module + + + + + + + + + + + + + + + + + + + + + + +
+

Quickstart

+

From zero to a module on screen

+

Two routes: download the released JAR, or build it yourself. The download needs no credentials; building does, because Gradle resolves mirror-api from GitHub Packages.

+ + + +

Route A β€” use the released JAR

+

Every v* tag publishes the built JAR as a release asset. Downloading an asset does not go through GitHub Packages, so no token is involved:

+
curl -LO https://github.com/pierrejochem/SpeculumExampleModule/releases/latest/download/SpeculumExampleModule.jar
+cp SpeculumExampleModule.jar /path/to/Speculum/plugins/
+

Restart the mirror and the module appears at top_center. The current release is v1.1.0, which needs mirror-api 1.3.0 β€” that is Speculum v1.3.0 or newer.

+ +

Route B β€” requirements

+
    +
  • JDK 17–21. The bundled Gradle wrapper runs on these; a newer default java needs JAVA_HOME or org.gradle.java.home pointed at one.
  • +
  • A GitHub personal access token with the read:packages scope.
  • +
+
Why a token for a public package: org.speculum:mirror-api is public, but GitHub's Maven registry still requires authentication for every download. This is a registry policy, not a permission on the package.
+ +

Credentials for GitHub Packages

+

Keep them out of the repository. Either put them in your user Gradle properties:

+
# ~/.gradle/gradle.properties
+gpr.user=<your-github-username>
+gpr.token=<a-PAT-with-read:packages>
+

…or export them instead:

+
export GITHUB_ACTOR=<your-github-username>
+export GITHUB_TOKEN=<a-PAT-with-read:packages>
+
Never commit the token. The repository's own gradle.properties deliberately contains no credentials β€” only a comment pointing here.
+ +

Build

+
git clone https://github.com/pierrejochem/SpeculumExampleModule.git
+cd SpeculumExampleModule
+./gradlew jar
+

The JAR lands in build/libs/SpeculumExampleModule.jar. Check the thin-JAR contract β€” it should hold only the example classes and the service file, with no Compose or mirror-api classes inside:

+
unzip -l build/libs/SpeculumExampleModule.jar
+

./gradlew build additionally runs ktlint, detekt and the test suite.

+ +

Deploy and run

+

Point the deployToMirror task at a Speculum checkout or install:

+
./gradlew deployToMirror -Pspeculum.pluginsDir=/path/to/Speculum/plugins
+

The SPECULUM_PLUGINS_DIR environment variable does the same. With neither set the JAR is copied to build/plugins/ for you to move yourself.

+

Then start the mirror the usual way (see Speculum's Getting Started). The module is discovered on startup β€” nothing to register.

+ +

Choosing a mirror-api version

+

mirror-api is published on every v* tag of the Speculum project; the artifact version is the tag without its leading v. Set it in gradle/libs.versions.toml:

+
[versions]
+mirrorApi = "1.3.0"
+

Pin the oldest version whose API you actually use β€” a module compiled against an older mirror-api still loads in a newer host, because added SPI methods carry default implementations. settingsSchema() is the reason this project needs 1.3.0.

+
+ + + + diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..b34848b --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://pierrejochem.github.io/SpeculumExampleModule/sitemap.xml diff --git a/docs/settings-schema.html b/docs/settings-schema.html new file mode 100644 index 0000000..47bd28a --- /dev/null +++ b/docs/settings-schema.html @@ -0,0 +1,163 @@ + + + + + +Settings Schema β€” Speculum Example Module + + + + + + + + + + + + + + + + + + + + + + +
+

Settings Schema

+

Make your options real controls

+

The Speculum admin console knows nothing about any module by name. It renders whatever settingsSchema() returns β€” declare your keys and they become labelled, typed, validated inputs.

+ + + +

What changes

+

Without a schema, every option in the console is an untyped pair of text boxes: the author types the key, then the value, with no label, no bounds and no hint about what is allowed. With one, the console draws a dropdown for an enum, a checkbox for a boolean, a bounded number field for an int, and a sentence of help underneath.

+

The schema travels from your JAR to the browser: the config-server loads your factory through the same ServiceLoader the mirror uses and serves the result on GET /api/modules.

+
Added in mirror-api 1.3.0, which ships with Speculum v1.3.0. The method has a default empty implementation, so older plugins keep compiling and loading unchanged.
+ +

The example module's schema

+

This module reads two keys, greeting and tickStep, and declares both:

+
override fun settingsSchema(): List<SettingSpec> =
+    listOf(
+        SettingSpec(
+            key = "greeting",
+            label = "Greeting",
+            default = "Hello, Speculum!",
+            help = "Headline text shown at the top of the module.",
+        ),
+        SettingSpec(
+            key = "tickStep",
+            label = "Tick step",
+            type = SettingType.INT,
+            default = "1",
+            min = 1,
+            max = 100,
+            help = "How much the refresh counter advances on each refresh.",
+        ),
+    )
+ +

Types

+
+ + + + + + + + + + + + +
TypeControlNotes
STRINGSingle-line text fieldThe default when type is omitted.
INTNumber fieldBounded by min/max, stepped by step.
BOOLCheckboxStores the strings "true"/"false".
ENUMDropdown over optionsA saved value no longer offered stays selectable rather than being silently rewritten.
TEXTTextareaFor multi-line values.
URLFull-width text fieldWith URL-shaped validation.
IPDropdown of detected host addressesPlus an auto-detect entry.
CUSTOMA purpose-built editorLooked up by editor name; falls back to a textarea when the console has none.
+
+
SettingSpec(
+    key = "units", label = "Units", type = SettingType.ENUM,
+    default = "metric", options = listOf("metric", "imperial"),
+)
+ +

default vs. defaultConfig()

+

These two are easy to conflate and mean different things:

+
+ + + + + + + +
SettingSpec.defaultdefaultConfig()
What it isThe fallback your module code reads when the key is absent.The starting config a freshly added module is given.
Where it showsAs the hint in an empty field.As an actual saved value.
Stored?No.Yes, written into config.json.
+
+

They may legitimately differ. This module's tickStep has default = "1" because config.int("tickStep", 1) is what the code falls back to, while defaultConfig() seeds "2" to make the counter visibly move on first run.

+
Keep default in sync with your code. It is the number the console shows as "what happens if I leave this blank". If the two drift, the hint lies.
+ +

Advanced, help and placeholders

+

help renders as a sentence under the control; placeholder fills an empty text field; advanced = true folds an option behind a "show advanced" toggle, which is where rarely-touched tuning belongs:

+
SettingSpec(
+    key = "fadeSpeed", label = "Fade (ms)", type = SettingType.INT,
+    default = "4000", min = 0, max = 60000, step = 500,
+    advanced = true,
+)
+ +

Preview templates

+

When the effective value is assembled from several keys, preview shows it live. Templates are tried in order; {key} interpolates and {key|fallback} fills in a blank. The first template whose plain references all resolve wins:

+
SettingSpec(
+    key = "url", label = "URL override", type = SettingType.URL,
+    help = "Leave blank to build the address from the IP and port.",
+    preview = listOf("{url}", "http://{ip|<auto LAN IP>}:{port|8080}"),
+)
+ +

What happens without a schema

+

Nothing breaks. A module that declares no schema keeps the raw key/value rows, exactly as before the feature existed β€” which is why plugins built against an older mirror-api still work. The same applies per key: anything saved in a config but absent from the schema falls back to a raw row, so you can describe part of a module and grow the schema later.

+ +

Testing your schema

+

The cheapest guard is asserting that the schema covers everything defaultConfig() ships, which catches a key added to one and forgotten in the other:

+
@Test
+fun `settingsSchema declares every key the default config ships`() {
+    val undeclared = factory.settingsSchema().undeclaredKeys(factory.defaultConfig().config)
+
+    assertEquals(emptyList(), undeclared, "defaultConfig keys missing from settingsSchema")
+}
+

undeclaredKeys is an extension on List<SettingSpec> in org.speculum.config; defaults() alongside it returns the declared defaults as a map. Full reference in the mirror-api docs.

+
+ + + + diff --git a/docs/sitemap.xml b/docs/sitemap.xml new file mode 100644 index 0000000..0bfc5d8 --- /dev/null +++ b/docs/sitemap.xml @@ -0,0 +1,27 @@ + + + + https://pierrejochem.github.io/SpeculumExampleModule/ + 2026-10-04 + monthly + 1.0 + + + https://pierrejochem.github.io/SpeculumExampleModule/quickstart.html + 2026-10-04 + monthly + 0.9 + + + https://pierrejochem.github.io/SpeculumExampleModule/module-anatomy.html + 2026-10-04 + monthly + 0.9 + + + https://pierrejochem.github.io/SpeculumExampleModule/settings-schema.html + 2026-10-04 + monthly + 0.9 + +