Skip to content
helmstudio
Contents

Theme with helm-css

helm-css is the design system helmstudio's own screens are built with: tokens for colour, space, type and motion in a dark and a light theme, and classes for layout and components, all prefixed helm-. A studio that wears it looks like it belongs, follows the theme the person chose in the launcher, and keeps its own colour.

This guide builds a small studio, lantern studio, whose page does all three. It asks for no capabilities: theming needs no token.

theming/helmstudio.yaml
# A studio whose page wears helm-css, follows the launcher's theme, and is
# drawn in its own colour. One process, and no capabilities: theming needs no
# token.
id: lantern-studio
name: lantern studio
description: A page on helm-css, in both themes and its own hue.
kinds: [image]
license: MIT
repo: https://github.com/you/lantern-studio
hue: { dark: "#e8745a", light: "#b0442b" }
requires:
  os: [darwin, linux]
  arch: [arm64, amd64]
runtime:
  framework: other
  backends: [cpu]
sdk: { runtime: "^1", css: "^1" }
processes:
  - name: studio
    role: main
    cmd: "python3 server.py --port {port}"
    port: { prefer: 8767 }
    health: { path: /healthz, timeout_s: 30 }
    ui: /

The server mounts the proxy

A page reaches helmstudio through its own server, at /helm/, never directly. The runtime SDK's proxy answers three things there: helm-css and the other SDK files at /helm/sdk/v1/, the studio's colour at /helm/accent.css, and the platform API at /helm/api/v1/, with the studio's token added on the way. The page never holds the token, and a launcher operation — install, launch, stop — is never forwarded.

theming/server.py
"""lantern studio's server: its page, its stylesheet, and helmstudio's proxy.

The proxy is mounted at /helm/, so the page reaches helm-css, its own hue and
the theme stream from its own origin, and never holds a token.
"""

import argparse
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from helm_runtime_sdk.proxy import Proxy

HERE = os.path.dirname(os.path.abspath(__file__))
PAGES = {"/": ("index.html", "text/html; charset=utf-8"), "/studio.css": ("studio.css", "text/css; charset=utf-8")}

# HELM_API, HELM_SDK_BASE and the hue are set by helmstudio, or by helm dev.
proxy = Proxy.from_env()


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if proxy.handle_http(self):
            return
        if self.path == "/healthz":
            return self.reply(200, b"ok", "text/plain")
        if self.path in PAGES:
            name, kind = PAGES[self.path]
            with open(os.path.join(HERE, name), "rb") as f:
                return self.reply(200, f.read(), kind)
        self.reply(404, b"not found", "text/plain")

    do_HEAD = do_GET

    def reply(self, status, body, kind):
        self.send_response(status)
        self.send_header("Content-Type", kind)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def log_message(self, format, *args):
        pass


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, required=True)
    args = parser.parse_args()
    print("lantern studio: http://127.0.0.1:%d" % args.port, flush=True)
    ThreadingHTTPServer(("127.0.0.1", args.port), Handler).serve_forever()


if __name__ == "__main__":
    main()

The Go and JavaScript runtime SDKs ship the same proxy, and the conformance suite holds all three to the same behaviour. The same server in Go:

theming/go/main.go
// lantern studio's server in Go: the same page and stylesheet, and the
// runtime SDK's proxy mounted at /helm/.
package main

import (
	"flag"
	"fmt"
	"log"
	"net/http"

	helm "github.com/janishar/helmstudio/packages/helm-runtime-sdk/go"
)

func main() {
	port := flag.Int("port", 0, "the port helmstudio assigns")
	flag.Parse()

	mux := http.NewServeMux()
	// HELM_API, HELM_SDK_BASE and the hue are set by helmstudio, or by helm dev.
	mux.Handle(helm.ProxyPrefix, helm.Proxy(helm.ProxyFromEnv()))
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "index.html") })
	mux.HandleFunc("GET /studio.css", func(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, "studio.css") })
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") })

	addr := fmt.Sprintf("127.0.0.1:%d", *port)
	fmt.Printf("lantern studio: http://%s\n", addr)
	log.Fatal(http.ListenAndServe(addr, mux))
}

And in JavaScript. @helmstudio/runtime is not on npm yet, so install it from the clone, with npm install and the path to packages/helm-runtime-sdk/node:

theming/node/server.mjs
// lantern studio's server in JavaScript: the same page and stylesheet, and the
// runtime SDK's proxy mounted at /helm/.
import http from "node:http";
import { readFile } from "node:fs/promises";
import { createProxy } from "@helmstudio/runtime/proxy";

const pages = {
  "/": ["index.html", "text/html; charset=utf-8"],
  "/studio.css": ["studio.css", "text/css; charset=utf-8"],
};
const port = Number(process.argv[process.argv.indexOf("--port") + 1]);

// HELM_API, HELM_SDK_BASE and the hue are set by helmstudio, or by helm dev.
const helmProxy = createProxy();

http.createServer(async (req, res) => {
  if (await helmProxy(req, res)) return;
  const path = new URL(req.url, "http://studio").pathname;
  if (path === "/healthz") return res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
  const page = pages[path];
  if (!page) return res.writeHead(404, { "Content-Type": "text/plain" }).end("not found");
  res.writeHead(200, { "Content-Type": page[1] }).end(await readFile(page[0]));
}).listen(port, "127.0.0.1", () => console.log(`lantern studio: http://127.0.0.1:${port}`));
theming/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lantern studio</title>
<!-- helm-css, and this studio's hue for each theme, through the proxy -->
<link rel="stylesheet" href="/helm/sdk/v1/helm.css">
<link rel="stylesheet" href="/helm/accent.css">
<link rel="stylesheet" href="/studio.css">
<script type="module">
  import { connect, themeBridge } from "/helm/sdk/v1/helm-runtime.js";
  // Follow the launcher's System, Light and Dark as the person changes them.
  themeBridge();
  window.helm = connect();
</script>
</head>
<body class="helm-body">
  <main class="helm-main">
    <input class="prompt" placeholder="a lighthouse at dusk">
    <button class="helm-btn make">Make</button>
  </main>
</body>
</html>

themeBridge() follows the launcher's theme stream, and sets data-theme on the page as the person switches between System, Light and Dark. With no stream, as when the page is opened on its own, the page follows the operating system.

Behind /helm/sdk/v1/, the proxy serves the major the studio pins in sdk. helmstudio refuses to launch a studio that pins a major it does not serve, so an update never moves a studio across one.

The studio's own stylesheet uses tokens

theming/studio.css
/* lantern studio's own stylesheet. Every colour is a token, so it follows the
   launcher's theme; the studio's hue comes from /helm/accent.css. */
.prompt {
  width: 100%;
  padding: var(--helm-space-2) var(--helm-space-3);
  border: 1px solid var(--helm-border-strong);
  border-radius: var(--helm-radius-sm);
  background: var(--helm-ground-page);
  color: var(--helm-text-primary);
  font: var(--helm-type-body);
}

/* The studio's primary action, in its own hue. */
.make {
  background: var(--helm-studio-accent);
  color: var(--helm-on-studio-accent);
}

Every colour is a token, so the stylesheet is right in both themes without a line for either. --helm-studio-accent is the studio's hue for whichever theme is showing, and --helm-on-studio-accent is the text colour that contrasts with it. A studio with no hue is given one from a fixed set, chosen by its id, so it is the same at every launch.

In the launcher, a studio's hue is only ever a stripe, a dot and its clips on the timeline. On the studio's own page it is the studio's to use, for its main action as here.

Lint it

theming/lint.sh
helm validate -strict -theme .

helm validate -theme reads a studio's stylesheets and flags colour literals and font families other than IBM Plex, skipping a vendored copy of helm-css under vendor/helm/. It is advisory, and -strict makes a finding fail, as a registry check would. Give -strict before -theme: after the directory, it is read as a file name.

It does not check colours set from JavaScript, or contrast as rendered. Theme conformance is criterion 12, and scoring it needs the studio's stylesheets and a rendered page, so nothing scores it yet.