From 2503289cca5bd54720e4577b2491951e1ca5d170 Mon Sep 17 00:00:00 2001 From: Pierre Jochem Date: Sun, 4 Oct 2026 14:02:50 +0200 Subject: [PATCH] docs: publish a developer guide on GitHub Pages and optimize for search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repository explained itself only through the README, which meant the one thing it exists to teach β€” how to write a Speculum plugin β€” was not reachable from a search engine, and link previews of it were bare. Adds a four-page static site under docs/, built on the same stylesheet as the Speculum documentation so the two read as one project: - index.html what a plugin is and how the host discovers one - quickstart.html the released JAR or a source build, then deployment - module-anatomy.html every file in the project and what the host does with it - settings-schema.html declaring options the admin console renders Search-engine groundwork on every page: a unique title and meta description, a canonical link, Open Graph and Twitter Card tags pointing at a generated 1200x630 card, and one h1. index.html also carries JSON-LD describing the project as SoftwareSourceCode plus the guide as a TechArticle. robots.txt and sitemap.xml cover all four pages, and .nojekyll keeps Pages from reprocessing the output. The README now links the guide instead of growing further. --- README.md | 17 ++- docs/.nojekyll | 0 docs/assets/css/style.css | 254 +++++++++++++++++++++++++++++++++++++ docs/assets/img/icon.svg | 20 +++ docs/assets/img/social.png | Bin 0 -> 44105 bytes docs/index.html | 141 ++++++++++++++++++++ docs/module-anatomy.html | 173 +++++++++++++++++++++++++ docs/quickstart.html | 113 +++++++++++++++++ docs/robots.txt | 4 + docs/settings-schema.html | 163 ++++++++++++++++++++++++ docs/sitemap.xml | 27 ++++ 11 files changed, 911 insertions(+), 1 deletion(-) create mode 100644 docs/.nojekyll create mode 100644 docs/assets/css/style.css create mode 100644 docs/assets/img/icon.svg create mode 100644 docs/assets/img/social.png create mode 100644 docs/index.html create mode 100644 docs/module-anatomy.html create mode 100644 docs/quickstart.html create mode 100644 docs/robots.txt create mode 100644 docs/settings-schema.html create mode 100644 docs/sitemap.xml 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 0000000000000000000000000000000000000000..c243dfd68d94835b334505d536cde6698de0cab5 GIT binary patch literal 44105 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 + + + + +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 + +