changeset 2372:06bf6dae8e64

reorg tools into light9/web/ and a single vite instance
author drewp@bigasterisk.com
date Thu, 08 Jun 2023 13:20:23 -0700
parents 5e4321405f54
children 86e569fa59c7
files bin/ascoltami bin/rdfdb light9/ascoltami/Light9AscoltamiUi.ts light9/ascoltami/index.html light9/ascoltami/main.ts light9/ascoltami/vite.config.ts light9/collector/web/Light9CollectorDevice.ts light9/collector/web/Light9CollectorUi.ts light9/collector/web/index.html light9/collector/web/vite.config.ts light9/effect/listing/web/Light9EffectListing.ts light9/effect/listing/web/index.html light9/effect/listing/web/vite.config.ts light9/fade/Light9EffectFader.ts light9/fade/Light9FadeUi.ts light9/fade/Light9Fader.ts light9/fade/index.html light9/fade/vite.config.ts light9/homepage/ServiceButtonRow.ts light9/homepage/StatsLine.ts light9/homepage/StatsProcess.ts light9/homepage/write_config.py light9/live/Effect.ts light9/live/Light9AttrControl.ts light9/live/Light9DeviceControl.ts light9/live/Light9DeviceSettings.ts light9/live/Light9Listbox.ts light9/live/README.md light9/live/index.html light9/live/vite.config.ts light9/web/RdfdbSyncedGraph.ts light9/web/ascoltami/Light9AscoltamiUi.ts light9/web/ascoltami/index.html light9/web/ascoltami/main.ts light9/web/collector/Light9CollectorDevice.ts light9/web/collector/Light9CollectorUi.ts light9/web/collector/index.html light9/web/effects/Light9EffectListing.ts light9/web/effects/index.html light9/web/fade/Light9EffectFader.ts light9/web/fade/Light9FadeUi.ts light9/web/fade/Light9Fader.ts light9/web/fade/index.html light9/web/index.html light9/web/live/Effect.ts light9/web/live/Light9AttrControl.ts light9/web/live/Light9DeviceControl.ts light9/web/live/Light9DeviceSettings.ts light9/web/live/Light9Listbox.ts light9/web/live/README.md light9/web/live/index.html light9/web/metrics/ServiceButtonRow.ts light9/web/metrics/StatsLine.ts light9/web/metrics/StatsProcess.ts light9/web/vite.config.ts
diffstat 55 files changed, 2750 insertions(+), 2858 deletions(-) [+]
line wrap: on
line diff
--- a/bin/ascoltami	Thu Jun 08 12:28:27 2023 -0700
+++ b/bin/ascoltami	Thu Jun 08 13:20:23 2023 -0700
@@ -1,4 +1,2 @@
 #!/bin/zsh
-pnpm exec vite -c light9/ascoltami/vite.config.ts &
 pdm run uvicorn light9.ascoltami.main:app --host 0.0.0.0 --port 8206 --no-access-log
-wait
--- a/bin/rdfdb	Thu Jun 08 12:28:27 2023 -0700
+++ b/bin/rdfdb	Thu Jun 08 13:20:23 2023 -0700
@@ -1,4 +1,2 @@
 #!/bin/zsh
-#pnpm exec vite -c light9/ascoltami/vite.config.ts &
-pdm run uvicorn --port 8209 light9.rdfdb.service:app --no-access-log
-wait
+exec pdm run uvicorn --port 8209 light9.rdfdb.service:app --no-access-log
--- a/light9/ascoltami/Light9AscoltamiUi.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,310 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { classMap } from "lit/directives/class-map.js";
-import { NamedNode } from "n3";
-import Sylvester from "sylvester";
-import { Zoom } from "../web/light9-timeline-audio";
-import { PlainViewState } from "../web/Light9CursorCanvas";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { TimingUpdate } from "./main";
-import { showRoot } from "../web/show_specific";
-export { Light9TimelineAudio } from "../web/light9-timeline-audio";
-export { Light9CursorCanvas } from "../web/Light9CursorCanvas";
-export { RdfdbSyncedGraph } from "../web/RdfdbSyncedGraph";
-export { ResourceDisplay } from "../web/ResourceDisplay";
-const $V = Sylvester.Vector.create;
-
-debug.enable("*");
-const log = debug("asco");
-
-function byId(id: string): HTMLElement {
-  return document.getElementById(id)!;
-}
-async function postJson(url: string, jsBody: Object) {
-  return fetch(url, {
-    method: "POST",
-    headers: { "Content-Type": "applcation/json" },
-    body: JSON.stringify(jsBody),
-  });
-}
-@customElement("light9-ascoltami-ui")
-export class Light9AscoltamiUi extends LitElement {
-  graph!: SyncedGraph;
-  times!: { intro: number; post: number };
-  @property() nextText: string = "";
-  @property() isPlaying: boolean = false;
-  @property() show: NamedNode | null = null;
-  @property() song: NamedNode | null = null;
-  @property() selectedSong: NamedNode | null = null;
-  @property() currentDuration: number = 0;
-  @property() zoom: Zoom;
-  @property() overviewZoom: Zoom;
-  @property() viewState: PlainViewState | null = null;
-  static styles = [
-    css`
-      :host {
-        display: flex;
-        flex-direction: column;
-      }
-      .timeRow {
-        margin: 14px;
-        position: relative;
-      }
-      #overview {
-        height: 60px;
-      }
-      #zoomed {
-        margin-top: 40px;
-        height: 80px;
-      }
-      #cursor {
-        position: absolute;
-        left: 0;
-        top: 0;
-        width: 100%;
-        height: 100%;
-      }
-      #grow {
-        flex: 1 1 auto;
-        display: flex;
-      }
-      #grow > span {
-        display: flex;
-        position: relative;
-        width: 50%;
-      }
-      #playSelected {
-        height: 100px;
-      }
-      #songList {
-        overflow-y: scroll;
-        position: absolute;
-        left: 0;
-        top: 0;
-        right: 0;
-        bottom: 0;
-      }
-      #songList .row {
-        width: 60%;
-        min-height: 40px;
-        text-align: left;
-        position: relative;
-      }
-      #songList .row:nth-child(even) {
-        background: #333;
-      }
-      #songList .row:nth-child(odd) {
-        background: #444;
-      }
-      #songList button {
-        min-height: 40px;
-        margin-bottom: 10px;
-      }
-      #songList .row.playing {
-        box-shadow: 0 0 30px red;
-        background-color: #de5050;
-      }
-    `,
-  ];
-  render() {
-    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <link rel="stylesheet" href="./style.css" />
-
-      <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> -->
-
-      <div id="grow">
-        <span>
-          <div id="songList">
-            <table>
-              ${this.songList.map(
-                (song) => html`
-                  <tr
-                    class="row ${classMap({
-                      playing: !!(this.song && song.equals(this.song)),
-                    })}"
-                  >
-                    <td><resource-display .uri=${song} noclick></resource-display></td>
-                    <td>
-                      <button @click=${this.onSelectSong.bind(this, song)}>
-                        <span>Select</span>
-                      </button>
-                    </td>
-                  </tr>
-                `
-              )}
-            </table>
-          </div> </span
-        ><span>
-          <div id="right">
-            <div>
-              Selected:
-              <resource-display .uri=${this.selectedSong}></resource-display>
-            </div>
-            <div>
-              <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
-            </div>
-          </div>
-        </span>
-      </div>
-
-      <div class="timeRow">
-        <div id="timeSlider"></div>
-        <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
-        <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio>
-        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
-      </div>
-
-      <div class="commands">
-        <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}">
-          <strong>Stop</strong>
-          <div class="key">s</div>
-        </button>
-        <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}">
-          <strong>Play</strong>
-          <div class="key">p</div>
-        </button>
-        <button id="cmd-intro" @click=${this.onCmdIntro}>
-          <strong>Skip intro</strong>
-          <div class="key">i</div>
-        </button>
-        <button id="cmd-post" @click=${this.onCmdPost}>
-          <strong>Skip to Post</strong>
-          <div class="key">t</div>
-        </button>
-        <button id="cmd-go" @click=${this.onCmdGo}>
-          <strong>Go</strong>
-          <div class="key">g</div>
-          <div id="next">${this.nextText}</div>
-        </button>
-      </div>`;
-  }
-
-  onSelectSong(song: NamedNode, ev: MouseEvent) {
-    if (this.selectedSong && song.equals(this.selectedSong)) {
-      this.selectedSong = null;
-    } else {
-      this.selectedSong = song;
-    }
-  }
-  async onPlaySelected(ev: Event) {
-    if (!this.selectedSong) {
-      return;
-    }
-    await fetch("api/song", { method: "POST", body: this.selectedSong.value });
-  }
-
-  onCmdStop(ev?: MouseEvent): void {
-    postJson("api/time", { pause: true });
-  }
-  onCmdPlay(ev?: MouseEvent): void {
-    postJson("api/time", { resume: true });
-  }
-  onCmdIntro(ev?: MouseEvent): void {
-    postJson("api/time", { t: this.times.intro, resume: true });
-  }
-  onCmdPost(ev?: MouseEvent): void {
-    postJson("api/time", {
-      t: this.currentDuration - this.times.post,
-      resume: true,
-    });
-  }
-  onCmdGo(ev?: MouseEvent): void {
-    postJson("api/go", {});
-  }
-
-  bindKeys() {
-    document.addEventListener("keypress", (ev) => {
-      if (ev.which == 115) {
-        this.onCmdStop();
-        return false;
-      }
-      if (ev.which == 112) {
-        this.onCmdPlay();
-        return false;
-      }
-      if (ev.which == 105) {
-        this.onCmdIntro();
-        return false;
-      }
-      if (ev.which == 116) {
-        this.onCmdPost();
-        return false;
-      }
-
-      if (ev.key == "g") {
-        this.onCmdGo();
-        return false;
-      }
-      return true;
-    });
-  }
-
-  async musicSetup() {
-    // shoveled over from the vanillajs version
-    const config = await (await fetch("api/config")).json();
-    this.show = new NamedNode(config.show);
-    this.times = config.times;
-    document.title = document.title.replace("{{host}}", config.host);
-    try {
-      const h1 = document.querySelector("h1")!;
-      h1.innerText = h1.innerText.replace("{{host}}", config.host);
-    } catch (e) {}
-
-    (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
-  }
-
-  onOldStyleUpdate(data: TimingUpdate) {
-    this.nextText = data.next;
-    this.isPlaying = data.playing;
-    this.currentDuration = data.duration;
-    this.song = new NamedNode(data.song);
-    this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
-    const t1 = data.t - 2,
-      t2 = data.t + 20;
-    this.zoom = { duration: data.duration, t1, t2 };
-    const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
-    const w = timeRow.offsetWidth;
-    this.viewState = {
-      zoomSpec: { t1: () => t1, t2: () => t2 },
-      cursor: { t: () => data.t },
-      audioY: () => 0,
-      audioH: () => 60,
-      zoomedTimeY: () => 60,
-      zoomedTimeH: () => 40,
-      fullZoomX: (sec: number) => (sec / data.duration) * w,
-      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
-      mouse: { pos: () => $V([0, 0]) },
-    };
-  }
-
-  @property() songList: NamedNode[] = [];
-  constructor() {
-    super();
-    this.bindKeys();
-    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.musicSetup(); // async
-      this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
-    });
-  }
-  graphChanged() {
-    this.songList = [];
-    try {
-      const playList = this.graph.uriValue(
-        //
-        this.graph.Uri(showRoot),
-        this.graph.Uri(":playList")
-      );
-      log(playList);
-      this.songList = this.graph.items(playList) as NamedNode[];
-    } catch (e) {
-      log("no playlist yet");
-    }
-    log(this.songList.length);
-  }
-}
--- a/light9/ascoltami/index.html	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>ascoltami on {{host}}</title>
-    <link rel="stylesheet" href="./style.css" />
-    <style>
-      #cmd-go {
-        min-width: 5em;
-      }
-      .song-name {
-        padding-left: 0.4em;
-      }
-      .dimStalled #currentTime {
-        font-size: 20px;
-        background: green;
-        color: black;
-        padding: 3px;
-      }
-      .dimStalled {
-        font-size: 90%;
-      }
-      body {
-        margin: 0;
-        padding: 0;
-        overflow: hidden;
-        min-height: 100vh;
-      }
-      #page {
-        width: 100%;
-        height: 100vh; /* my phone was losing the bottom :( */
-        display: flex;
-        flex-direction: column;
-      }
-      #page > div,
-      #page > p {
-        flex: 0 1 auto;
-        margin: 0;
-      }
-      light9-ascoltami-ui {
-        flex: 1 1 auto;
-      }
-    </style>
-    <meta
-      name="viewport"
-      content="user-scalable=no, width=device-width, initial-scale=.7"
-    />
-    <script type="module" src="../ascoltami/Light9AscoltamiUi"></script>
-  </head>
-  <body>
-    <div id="page">
-      <h1>ascoltami on {{host}}</h1>
-      <div class="songs" style="display: none"></div>
-
-      <div class="dimStalled">
-        <table>
-          <tr>
-            <td colspan="3">
-              <strong>Song:</strong> <span id="currentSong"></span>
-            </td>
-          </tr>
-          <tr>
-            <td><strong>Time:</strong> <span id="currentTime"></span></td>
-            <td><strong>Left:</strong> <span id="leftTime"></span></td>
-            <td>
-              <strong>Until autostop:</strong>
-              <span id="leftAutoStopTime"></span>
-            </td>
-          </tr>
-          <tr>
-            <td colspan="3">
-               <span id="states"></span>
-            </td>
-          </tr>
-        </table>
-      </div>
-
-      <hr />
-      <light9-ascoltami-ui></light9-ascoltami-ui>
-      <p><a href="">reload</a></p>
-    </div>
-    <script type="module" src="../ascoltami/main.ts"></script>
-  </body>
-</html>
--- a/light9/ascoltami/main.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,100 +0,0 @@
-function byId(id: string): HTMLElement {
-  return document.getElementById(id)!;
-}
-
-export interface TimingUpdate {
-  // GET /ascoltami/time response
-  duration: number;
-  next: string; // e.g. 'play'
-  playing: boolean;
-  song: string;
-  started: number; // unix sec
-  t: number; // seconds into song
-  state: { current: { name: string }; pending: { name: string } };
-}
-
-(window as any).finishOldStyleSetup = async (times: { intro: number; post: number }, timingUpdate: (data: TimingUpdate) => void) => {
-  let currentHighlightedSong = "";
-  // let lastPlaying = false;
-
-  
-  const events = new EventSource("api/time/stream");
-  events.addEventListener("message", (m)=>{
-    const update = JSON.parse(m.data) as TimingUpdate
-    updateCurrent(update)
-    markUpdateTiming();
-  })
-
-  async function updateCurrent(data:TimingUpdate) {
-    byId("currentSong").innerText = data.song;
-    if (data.song != currentHighlightedSong) {
-      showCurrentSong(data.song);
-    }
-    byId("currentTime").innerText = data.t.toFixed(1);
-    byId("leftTime").innerText = (data.duration - data.t).toFixed(1);
-    byId("leftAutoStopTime").innerText = Math.max(0, data.duration - times.post - data.t).toFixed(1);
-    byId("states").innerText = JSON.stringify(data.state);
-    //   document.querySelector("#timeSlider").slider({ value: data.t, max: data.duration });
-    timingUpdate(data);
-  }
-  let recentUpdates: Array<number> = [];
-  function markUpdateTiming() {
-    recentUpdates.push(+new Date());
-    recentUpdates = recentUpdates.slice(Math.max(recentUpdates.length - 5, 0));
-  }
-
-  function refreshUpdateFreqs() {
-    if (recentUpdates.length > 1) {
-      if (+new Date() - recentUpdates[recentUpdates.length - 1] > 1000) {
-        byId("updateActual").innerText = "(stalled)";
-        return;
-      }
-
-      var avgMs = (recentUpdates[recentUpdates.length - 1] - recentUpdates[0]) / (recentUpdates.length - 1);
-      byId("updateActual").innerText = "" + Math.round(1000 / avgMs);
-    }
-  }
-  setInterval(refreshUpdateFreqs, 2000);
-
-  function showCurrentSong(uri: string) {
-    document.querySelectorAll(".songs div").forEach((row: Element, i: number) => {
-      if (row.querySelector("button")!.dataset.uri == uri) {
-        row.classList.add("currentSong");
-      } else {
-        row.classList.remove("currentSong");
-      }
-    });
-    currentHighlightedSong = uri;
-  }
-
-  const data = await (await fetch("api/songs")).json();
-  data.songs.forEach((song: { uri: string; label: string }) => {
-    const button = document.createElement("button");
-    // link is just for dragging, not clicking
-    const link = document.createElement("a");
-    const n = document.createElement("span");
-    n.classList.add("num");
-    n.innerText = song.label.slice(0, 2);
-    link.appendChild(n);
-
-    const sn = document.createElement("span");
-    sn.classList.add("song-name");
-    sn.innerText = song.label.slice(2).trim();
-    link.appendChild(sn);
-    link.setAttribute("href", song.uri);
-    link.addEventListener("click", (ev) => {
-      ev.stopPropagation();
-      button.click();
-    });
-    button.appendChild(link);
-    button.dataset.uri = song.uri;
-    button.addEventListener("click", async (ev) => {
-      await fetch("api/song", { method: "POST", body: song.uri });
-      showCurrentSong(song.uri);
-    });
-    const dv = document.createElement("div");
-    dv.appendChild(button);
-    document.querySelector(".songs")!.appendChild(dv);
-  });
-
-};
--- a/light9/ascoltami/vite.config.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8206;
-export default defineConfig({
-  base: "/ascoltami/",
-  root: "./light9/ascoltami",
-  publicDir: "../web",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/collector/web/Light9CollectorDevice.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-export { ResourceDisplay } from "../../web/ResourceDisplay";
-
-const log = debug("device-el");
-
-@customElement("light9-collector-device")
-export class Light9CollectorDevice extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        break-inside: avoid-column;
-        font-size: 80%;
-      }
-      h3 {
-        margin-top: 12px;
-        margin-bottom: 0;
-      }
-      td {
-        white-space: nowrap;
-      }
-
-      td.nonzero {
-        background: #310202;
-        color: #e25757;
-      }
-      td.full {
-        background: #2b0000;
-        color: red;
-        font-weight: bold;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <h3><resource-display .uri=${this.uri}></resource-display></h3>
-      <table class="borders">
-        <tr>
-          <th>out attr</th>
-          <th>value</th>
-          <th>chan</th>
-        </tr>
-        ${this.attrs.map(
-          (item) => html`
-            <tr>
-              <td>${item.attr}</td>
-              <td class=${item.valClass}>${item.val} →</td>
-              <td>${item.chan}</td>
-            </tr>
-          `
-        )}
-      </table>
-    `;
-  }
-  @property({
-    converter: acceptStringOrUri(),
-  })
-  uri: NamedNode = new NamedNode("");
-  @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = [];
-
-  setAttrs(attrs: any) {
-    this.attrs = attrs;
-    this.attrs.forEach(function (row: any) {
-      row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : "";
-    });
-  }
-}
-
-function acceptStringOrUri() {
-  return (s: string | null) => new NamedNode(s || "");
-}
--- a/light9/collector/web/Light9CollectorUi.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,91 +0,0 @@
-import debug from "debug";
-import { html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import ReconnectingWebSocket from "reconnectingwebsocket";
-import { sortBy, uniq } from "underscore";
-import { Light9CollectorDevice } from "./Light9CollectorDevice";
-import { Patch } from "../../web/patch";
-import { getTopGraph } from "../../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../../web/SyncedGraph";
-export { RdfdbSyncedGraph } from "../../web/RdfdbSyncedGraph";
-export { Light9CollectorDevice };
-
-debug.enable("*");
-const log = debug("collector");
-
-@customElement("light9-collector-ui")
-export class Light9CollectorUi extends LitElement {
-  graph!: SyncedGraph;
-  render() {
-    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
-      <h1>Collector <a href="metrics">[metrics]</a></h1>
-
-      <h2>Devices</h2>
-      <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device .uri=${d}></light9-collector-device>`)}</div> `;
-  }
-
-  @property() devices: NamedNode[] = [];
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
-    });
-
-    const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "api/updates");
-    ws.addEventListener("message", (ev: any) => {
-      const outputAttrsSet = JSON.parse(ev.data).outputAttrsSet;
-      if (outputAttrsSet) {
-        this.updateDev(outputAttrsSet.dev, outputAttrsSet.attrs);
-      }
-    });
-  }
-
-  findDevices(patch?: Patch) {
-    const U = this.graph.U();
-
-    this.devices = [];
-    this.clearDeviceChildElementCache();
-    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
-    uniq(sortBy(classes, "value"), true).forEach((dc) => {
-      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
-        this.devices.push(dev as NamedNode);
-      });
-    });
-  }
-
-  deviceElements: Map<string, Light9CollectorDevice> = new Map();
-
-  clearDeviceChildElementCache() {
-    this.deviceElements = new Map();
-  }
-
-  findDeviceChildElement(uri: string): Light9CollectorDevice | undefined {
-    const known = this.deviceElements.get(uri);
-    if (known) {
-      return known;
-    }
-
-    for (const el of this.shadowRoot!.querySelectorAll("light9-collector-device")) {
-      const eld = el as Light9CollectorDevice;
-      if (eld.uri.value == uri) {
-        this.deviceElements.set(uri, eld);
-        return eld;
-      }
-    }
-
-    return undefined;
-  }
-
-  updateDev(uri: string, attrs: { attr: string; chan: string; val: string; valClass: string }[]) {
-    const el = this.findDeviceChildElement(uri);
-    if (!el) {
-      // unresolved race: updates come in before we have device elements to display them
-      setTimeout(() => this.updateDev(uri, attrs), 300);
-      return;
-    }
-    el.setAttrs(attrs);
-  }
-}
--- a/light9/collector/web/index.html	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>collector</title>
-    <meta charset="utf-8" />
-
-    <link rel="stylesheet" href="./style.css" />
-    <script type="module" src="../collector/Light9CollectorUi"></script>
-
-    <style>
-      td {
-        white-space: nowrap;
-      }
-    </style>
-  </head>
-  <body>
-    <light9-collector-ui></light9-collector-ui>
-  </body>
-</html>
--- a/light9/collector/web/vite.config.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8202;
-export default defineConfig({
-  base: "/collector/",
-  root: "./light9/collector/web",
-  publicDir: "../../web",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/effect/listing/web/Light9EffectListing.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,113 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { sortBy } from "underscore";
-import { getTopGraph } from "../../../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../../../web/SyncedGraph";
-export { ResourceDisplay } from "../../../web/ResourceDisplay";
-
-debug.enable("*");
-const log = debug("listing");
-
-@customElement("light9-effect-listing")
-export class Light9EffectListing extends LitElement {
-  render() {
-    return html`
-      <h1>Effects</h1>
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      ${this.effects.map((e: NamedNode) => html`<light9-effect-class .uri=${e}></light9-effect-class>`)}
-    `;
-  }
-  graph!: SyncedGraph;
-  effects: NamedNode[] = [];
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.getClasses.bind(this), "getClasses");
-    });
-  }
-
-  getClasses() {
-    const U = this.graph.U();
-    this.effects = this.graph.subjects(U("rdf:type"), U(":Effect")) as NamedNode[];
-    this.effects = sortBy(this.effects, (ec: NamedNode) => {
-      try {
-        return this.graph.stringValue(ec, U("rdfs:label"));
-      } catch (e) {
-        return ec.value;
-      }
-    });
-    this.requestUpdate();
-  }
-}
-
-@customElement("light9-effect-class")
-export class Light9EffectClass extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        padding: 5px;
-        border: 1px solid green;
-        background: #1e271e;
-        margin-bottom: 3px;
-      }
-      a {
-        color: #7992d0;
-        background: #00000859;
-        min-width: 4em;
-        min-height: 2em;
-        display: inline-block;
-        text-align: center;
-        vertical-align: middle;
-      }
-      resource-display {
-        min-width: 12em;
-        font-size: 180%;
-      }
-    `,
-  ];
-  render() {
-    if (!this.uri) {
-      return html`loading...`;
-    }
-    return html`
-      Effect
-      <resource-display .uri=${this.uri} rename></resource-display>
-      <a href="../live?effect=${this.uri.value}">Edit</a>
-      <iron-ajax id="songEffects" url="/effectEval/songEffects" method="POST" content-type="application/x-www-form-urlencoded"></iron-ajax>
-      <span style="float:right">
-        <button disabled @click=${this.onAdd}>Add to current song</button>
-        <button disabled @mousedown=${this.onMomentaryPress} @mouseup=${this.onMomentaryRelease}>Add momentary</button>
-      </span>
-    `;
-  }
-  graph!: SyncedGraph;
-  uri?: NamedNode;
-
-  onAdd() {
-    // this.$.songEffects.body = { drop: this.uri.value };
-    // this.$.songEffects.generateRequest();
-  }
-
-  onMomentaryPress() {
-    // this.$.songEffects.body = { drop: this.uri.value, event: "start" };
-    // this.lastPress = this.$.songEffects.generateRequest();
-    // return this.lastPress.completes.then((request: { response: { note: any } }) => {
-    //   return (this.lastMomentaryNote = request.response.note);
-    // });
-  }
-
-  onMomentaryRelease() {
-    // if (!this.lastMomentaryNote) {
-    //   return;
-    // }
-    // this.$.songEffects.body = { drop: this.uri.value, note: this.lastMomentaryNote };
-    // this.lastMomentaryNote = null;
-    // return this.$.songEffects.generateRequest();
-  }
-}
--- a/light9/effect/listing/web/index.html	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>effect listing</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css">    
-    <script type="module" src="../effectListing/Light9EffectListing"></script>
-  </head>
-  <body>
-    <light9-effect-listing></light9-effect-listing>
-  </body>
-</html>
--- a/light9/effect/listing/web/vite.config.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,21 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8218;
-export default defineConfig({
-  base: "/effectListing/",
-  root: "./light9/effect/listing/web",
-  publicDir: "../../..",
-  server: {
-    host: "0.0.0.0",
-    base: 'effectListing',
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/fade/Light9EffectFader.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,190 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { NamedNode, Quad } from "n3";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { showRoot } from "../web/show_specific";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { Patch } from "../web/patch";
-import { Literal } from "n3";
-export { Light9Fader } from "./Light9Fader";
-
-const log = debug("efffader")
-
-//////////////////////////////////////
-const RETURN_URI = new NamedNode("");
-const RETURN_FLOAT = 1;
-function get2Step<T extends NamedNode | number>(returnWhat: T, graph: SyncedGraph, subj1: NamedNode, pred1: NamedNode, pred2: NamedNode): T | undefined {
-  // ?subj1 ?pred1 ?x . ?x ?pred2 ?returned .
-  let x: NamedNode;
-  try {
-    x = graph.uriValue(subj1, pred1);
-  } catch (e) {
-    return undefined;
-  }
-  try {
-    if (typeof returnWhat === "object" && (returnWhat as NamedNode).termType == "NamedNode") {
-      return graph.uriValue(x, pred2) as T;
-    } else if (typeof returnWhat === "number") {
-      return graph.floatValue(x, pred2) as T;
-    }
-  } catch (e) {
-    return undefined;
-  }
-}
-function set2Step(
-  graph: SyncedGraph, //
-  subj1: NamedNode,
-  pred1: NamedNode,
-  baseName: string,
-  pred2: NamedNode,
-  newObjLiteral: Literal
-) { }
-
-function maybeUriValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): NamedNode | undefined {
-  try {
-    return graph.uriValue(s, p);
-  } catch (e) {
-    return undefined;
-  }
-}
-function maybeStringValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): string | undefined {
-  try {
-    return graph.stringValue(s, p);
-  } catch (e) {
-    return undefined;
-  }
-}
-function maybeFloatValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): number | undefined {
-  try {
-    return graph.floatValue(s, p);
-  } catch (e) {
-    return undefined;
-  }
-}
-
-//////////////////////////////////////
-class EffectFader {
-  constructor(public uri: NamedNode) { }
-  column: string = "unset";
-  effect?: NamedNode;
-  effectAttr?: NamedNode; // :strength
-  setting?: NamedNode; // we assume fader always has exactly one setting
-  value?: number;
-}
-
-@customElement("light9-effect-fader")
-export class Light9EffectFader extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        border: 2px gray outset;
-        background: #272727;
-      }
-      light9-fader {
-        margin: 0px;
-        width: 100%;
-      }
-    `,
-  ];
-  render() {
-    if (this.conf === undefined || this.conf.value === undefined) {
-      return html`...`;
-    }
-    return html`
-      <div><resource-display .uri=${this.uri}></resource-display>
-      <light9-fader .value=${this.conf.value} @change=${this.onSliderInput}></light9-fader>
-      <div>${this.conf.value.toPrecision(3)}</div>
-      <div>effect <edit-choice nounlink .uri=${this.conf.effect} @edited=${this.onEffectChange}></edit-choice></div>
-      <div>attr <edit-choice nounlink .uri=${this.conf.effectAttr} @edited=${this.onEffectAttrChange}></edit-choice></div>
-    `;
-  }
-
-  graph?: SyncedGraph;
-  ctx: NamedNode = new NamedNode(showRoot + "/fade");
-  @property() uri!: NamedNode;
-  @state() conf?: EffectFader; // compiled from graph
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.compile.bind(this, this.graph), `fader config ${this.uri.value}`);
-    });
-  }
-
-  private compile(graph: SyncedGraph) {
-    const U = graph.U();
-    this.conf = undefined;
-
-    const conf = new EffectFader(this.uri);
-
-    if (!graph.contains(this.uri, U("rdf:type"), U(":Fader"))) {
-      // not loaded yet, perhaps
-      return;
-    }
-
-    conf.column = maybeStringValue(graph, this.uri, U(":column")) || "unset";
-    conf.effect = maybeUriValue(graph, this.uri, U(":effect"));
-    conf.effectAttr = get2Step(RETURN_URI, graph, this.uri, U(":setting"), U(":effectAttr"));
-
-    this.conf = conf;
-    graph.runHandler(this.compileValue.bind(this, graph, this.conf), `fader config.value ${this.uri.value}`);
-  }
-
-  private compileValue(graph: SyncedGraph, conf: EffectFader) {
-    // external graph change -> conf.value
-    const U = graph.U();
-    conf.value = get2Step(RETURN_FLOAT, graph, this.uri, U(":setting"), U(":value"));
-    // since conf attrs aren't watched as property:
-    this.requestUpdate()
-  }
-
-  onSliderInput(ev: CustomEvent) {
-    // slider user input -> graph
-    if (this.conf === undefined) return;
-    this.conf.value = ev.detail.value
-    this.writeValueToGraph()
-  }
-
-  writeValueToGraph() {
-    // this.value -> graph
-    if (this.graph === undefined) {
-      return;
-    }
-    const U = this.graph.U();
-    if (this.conf === undefined) {
-      return;
-    }
-    if (this.conf.value === undefined) {
-      log(`value of ${this.uri} is undefined`)
-      return;
-    }
-    log('writeValueToGraph', this.conf.value)
-    const valueTerm = this.graph.LiteralRoundedFloat(this.conf.value);
-    const settingNode = this.graph.uriValue(this.uri, U(":setting"));
-    this.graph.patchObject(settingNode, this.graph.Uri(":value"), valueTerm, this.ctx);
-
-  }
-
-  onEffectChange(ev: CustomEvent) {
-    if (this.graph === undefined) {
-      return;
-    }
-    const { newValue } = ev.detail;
-    this.graph.patchObject(this.uri, this.graph.Uri(":effect"), newValue, this.ctx);
-  }
-
-  onEffectAttrChange(ev: CustomEvent) {
-    if (this.graph === undefined) {
-      return;
-    }
-    // const { newValue } = ev.detail;
-    // if (this.setting === undefined) {
-    //   this.setting = this.graph.nextNumberedResource(this.graph.Uri(":fade_set"));
-    //   this.graph.patchObject(this.uri, this.graph.Uri(":setting"), this.setting, this.ctx);
-    // }
-    // this.graph.patchObject(this.setting, this.graph.Uri(":effectAttr"), newValue, this.ctx);
-  }
-}
--- a/light9/fade/Light9FadeUi.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,169 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import * as N3 from "n3";
-import { NamedNode, Quad } from "n3";
-import { Patch } from "../web/patch";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { showRoot } from "../web/show_specific";
-import { SyncedGraph } from "../web/SyncedGraph";
-export { EditChoice } from "../web/EditChoice";
-export { Light9EffectFader } from "./Light9EffectFader";
-export { Light9Fader } from "./Light9Fader";
-
-debug.enable("*,autodep");
-const log = debug("fade");
-
-class FaderConfig {
-  constructor(public uri: NamedNode, public column: number) { }
-}
-
-class FadePage {
-  constructor(public uri: NamedNode) { }
-  faderConfigs: FaderConfig[] = [];
-}
-class FadePages {
-  pages: FadePage[] = [];
-}
-
-@customElement("light9-fade-ui")
-export class Light9FadeUi extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: block;
-        user-select: none; /* really this is only desirable during slider drag events */
-      }
-      .mappedToHw {
-        background: #393945;
-      }
-      #gm light9-fader {
-        width: 300px;
-      }
-    `,
-  ];
-  render() {
-    return html`
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <h1>Fade</h1>
-<div id="gm">
-  <light9-fader .value=${this.grandMaster} @change=${this.gmChanged}></light9-fader>grand master
-</div>
-      ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))}
-
-      <div><button @click=${this.addPage}>Add new page</button></div>
-    `;
-  }
-  private renderPage(page: FadePage): TemplateResult {
-    const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage);
-    return html`<div class="${mappedToHw ? "mappedToHw" : ""}">
-      <fieldset>
-        <legend>
-          Page
-          <resource-display rename .uri=${page.uri}></resource-display>
-          ${mappedToHw ? html`mapped to hardware sliders` : html`
-          <button @click=${(ev: Event) => this.mapThisToHw(page.uri)}>Map this to hw</button>
-          `}
-        </legend>
-        ${page.faderConfigs.map((fd) => html` <light9-effect-fader .uri=${fd.uri}></light9-effect-fader> `)}
-      </fieldset>
-    </div>`;
-  }
-
-  graph!: SyncedGraph;
-  ctx: NamedNode = new NamedNode(showRoot + "/fade");
-
-  @property() fadePages?: FadePages;
-  @property() currentHwPage?: NamedNode;
-  @property() grandMaster?: number;
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.compile.bind(this), `faders layout`);
-      this.graph.runHandler(this.compileGm.bind(this), `faders gm`);
-    });
-  }
-  connectedCallback(): void {
-    super.connectedCallback();
-  }
-
-  compile() {
-    const U = this.graph.U();
-    this.fadePages = undefined;
-    const fadePages = new FadePages();
-    for (let page of this.graph.subjects(U("rdf:type"), U(":FadePage"))) {
-      const fp = new FadePage(page as NamedNode);
-      try {
-        for (let fader of this.graph.objects(page, U(":fader"))) {
-          const colLit = this.graph.stringValue(fader, U(':column'))
-          fp.faderConfigs.push(new FaderConfig(fader as NamedNode, parseFloat(colLit)));
-        }
-        fp.faderConfigs.sort((a, b) => {
-          return a.column - (b.column);
-        });
-        fadePages.pages.push(fp);
-      } catch (e) { }
-    }
-    fadePages.pages.sort((a, b) => {
-      return a.uri.value.localeCompare(b.uri.value);
-    });
-    this.fadePages = fadePages;
-    this.currentHwPage = undefined;
-    try {
-      const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
-      this.currentHwPage = this.graph.uriValue(mc, U(":outputs"));
-    } catch (e) { }
-  }
-  compileGm() {
-    const U = this.graph.U();
-    this.grandMaster = undefined
-    let newVal
-    try {
-
-      newVal = this.graph.floatValue(U(':grandMaster'), U(':value'))
-    } catch (e) {
-      return
-    }
-    this.grandMaster = newVal;
-
-  }
-  gmChanged(ev: CustomEvent) {
-    const U = this.graph.U();
-    const newVal = ev.detail.value
-    // this.grandMaster = newVal;
-    this.graph.patchObject(U(':grandMaster'), U(':value'), this.graph.LiteralRoundedFloat(newVal), this.ctx)
-
-  }
-
-
-  mapThisToHw(page: NamedNode) {
-    const U = this.graph.U();
-    log("map to hw", page);
-    const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
-    this.graph.patchObject(mc, U(":outputs"), page, this.ctx);
-  }
-
-  addPage() {
-    const U = this.graph.U();
-    const uri = this.graph.nextNumberedResource(showRoot + "/fadePage");
-    const adds = [
-      //
-      new Quad(uri, U("rdf:type"), U(":FadePage"), this.ctx),
-      new Quad(uri, U("rdfs:label"), N3.DataFactory.literal("unnamed"), this.ctx),
-    ];
-    for (let n = 1; n <= 8; n++) {
-      const f = this.graph.nextNumberedResource(showRoot + "/fader");
-      const s = this.graph.nextNumberedResource(showRoot + "/faderset");
-      adds.push(new Quad(uri, U(":fader"), f, this.ctx));
-      adds.push(new Quad(f, U("rdf:type"), U(":Fader"), this.ctx));
-      adds.push(new Quad(f, U(":column"), N3.DataFactory.literal("" + n), this.ctx));
-      adds.push(new Quad(f, U(":setting"), s, this.ctx));
-      adds.push(new Quad(s, U(":effectAttr"), U(":strength"), this.ctx));
-      adds.push(new Quad(s, U(":value"), this.graph.LiteralRoundedFloat(0), this.ctx));
-    }
-    this.graph.applyAndSendPatch(new Patch([], adds));
-  }
-}
--- a/light9/fade/Light9Fader.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,146 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValueMap } from "lit";
-import { customElement, property, query } from "lit/decorators.js";
-
-import { clamp } from "../web/floating_color_picker";
-const log = debug("fade");
-
-class Drag {
-  constructor(public startDragPxY: number, public startDragValue: number) {}
-}
-
-@customElement("light9-fader")
-export class Light9Fader extends LitElement {
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        border: 2px gray inset;
-        background: #000;
-        height: 80px;
-      }
-      #handle {
-        background: gray;
-        border: 5px gray outset;
-        position: relative;
-        left: 0;
-        right: -25px;
-      }
-    `,
-  ];
-
-  @property() value: number = 0;
-
-  @query("#handle") handleEl!: HTMLElement;
-
-  troughHeight = 80 - 2 - 2 - 5 - 5;
-  handleHeight = 10;
-
-  drag?: Drag;
-  unmutedValue: number = 1;
-
-  render() {
-    return html` <div id="handle"><hr /></div> `;
-  }
-
-  protected update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
-    super.update(changedProperties);
-    if (changedProperties.has("value")) {
-      
-    }
-  }
-  valueChangedFromUi() {
-    this.value= clamp(this.value, 0, 1)
-    this.dispatchEvent(new CustomEvent("change", { detail: { value: this.value } }));
-  }
-
-  protected updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
-    super.updated(_changedProperties);
-    const y = this.sliderTopY(this.value);
-    this.handleEl.style.top = y + "px";
-  }
-
-  protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
-    super.firstUpdated(_changedProperties);
-    this.handleEl.style.height = this.handleHeight + "px";
-    this.events();
-  }
-
-  events() {
-    const hand = this.handleEl;
-    hand.addEventListener("mousedown", (ev: MouseEvent) => {
-      ev.stopPropagation();
-      if (ev.buttons == 1) {
-        this.drag = new Drag(ev.clientY, this.value);
-      } else if (ev.buttons == 2) {
-        this.onRmb();
-      }
-    });
-    this.addEventListener("mousedown", (ev: MouseEvent) => {
-      ev.stopPropagation();
-      if (ev.buttons == 1) {
-        this.value = this.sliderValue(ev.offsetY);
-        this.valueChangedFromUi()
-        this.drag = new Drag(ev.clientY, this.value);
-      } else if (ev.buttons == 2) {
-        // RMB in trough
-        this.onRmb();
-      }
-    });
-
-    this.addEventListener("contextmenu", (event) => {
-      event.preventDefault();
-    });
-
-    this.addEventListener("wheel", (ev: WheelEvent) => {
-      ev.preventDefault();
-      this.value += ev.deltaY / this.troughHeight * -.05;
-      this.valueChangedFromUi()
-    });
-
-    const maybeDrag = (ev: MouseEvent) => {
-      if (ev.buttons != 1) return;
-      if (this.drag === undefined) return;
-      ev.stopPropagation();
-      this.onMouseDrag(ev.clientY - this.drag.startDragPxY!);
-    };
-    hand.addEventListener("mousemove", maybeDrag);
-    this.addEventListener("mousemove", maybeDrag);
-    window.addEventListener("mousemove", maybeDrag);
-
-    hand.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
-    this.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
-    window.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
-  }
-  onRmb() {
-    if (this.value > 0.1) {
-      // mute
-      this.unmutedValue = this.value;
-      this.value = 0;
-    } else {
-      // unmute
-      this.value = this.unmutedValue;
-    }
-    this.valueChangedFromUi()
-  }
-  onMouseDrag(dy: number) {
-    if (this.drag === undefined) throw "unexpected";
-    this.value = this.drag.startDragValue - dy / this.troughHeight;
-    this.valueChangedFromUi()
-  }
-
-  onMouseUpAnywhere() {
-    this.drag = undefined;
-  }
-
-  sliderTopY(value: number): number {
-    const usableY = this.troughHeight - this.handleHeight;
-    const yAdj = this.handleHeight / 2 - 5 - 2;
-    return (1 - value) * usableY + yAdj;
-  }
-  sliderValue(offsetY: number): number {
-    const usableY = this.troughHeight - this.handleHeight;
-    const yAdj = this.handleHeight / 2 - 5 - 2;
-    return clamp(1 - (offsetY - yAdj) / usableY, 0, 1);
-  }
-}
--- a/light9/fade/index.html	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-<!doctype html>
-<html>
-  <head>
-    <title>fade</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css">    
-    <script src="node_modules/fpsmeter/dist/fpsmeter.min.js"></script>
-    <script type="module" src="./Light9FadeUi"></script>
-  </head>
-  <body>
-    <light9-fade-ui></light9-fade-ui>
-  </body>
-</html>
--- a/light9/fade/vite.config.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8219;
-export default defineConfig({
-  base: "/fade/",
-  root: "./light9/fade/",
-  publicDir: "../..",
-
-  server: {
-    host: "0.0.0.0",
-    base: "/fade/",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/homepage/ServiceButtonRow.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-import { LitElement, html, css } from "lit";
-import { customElement, property } from "lit/decorators.js";
-export { StatsLine } from "./StatsLine";
-
-@customElement("service-button-row")
-export class ServiceButtonRow extends LitElement {
-  @property() name: string = "?";
-  @property({ type:Boolean, attribute: "metrics" }) hasMetrics: boolean = false;
-  static styles = [
-    css`
-      :host {
-        padding-bottom: 10px;
-        border-bottom: 1px solid #333;
-      }
-      a {
-        color: #7d7dec;
-      }
-      div {
-        display: flex;
-        justify-content: space-between;
-        padding: 2px 3px;
-      }
-      .left {
-        display: inline-block;
-        margin-right: 3px;
-        flex-grow: 1;
-        min-width: 9em;
-      }
-      .window {
-      }
-      .serviceGrid > td {
-        border: 5px solid red;
-        display: inline-block;
-      }
-      .big {
-        font-size: 120%;
-        display: inline-block;
-        padding: 10px 0;
-      }
-
-      :host > div {
-        display: inline-block;
-        vertical-align: top;
-      }
-      :host > div:nth-child(2) {
-        width: 9em;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <div>
-        <div class="left"><a class="big" href="${this.name}/">${this.name}</a></div>
-        <div class="window"><button @click="${this.click}">window</button></div>
-        ${this.hasMetrics ? html`<div><a href="${this.name}/metrics">metrics</a></div>` : ""}
-      </div>
-
-      ${this.hasMetrics ? html`<div id="stats"><stats-line name="${this.name}"></div>` : ""}
-      `;
-  }
-
-  click() {
-    window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0");
-  }
-}
--- a/light9/homepage/StatsLine.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,306 +0,0 @@
-import { css, html, LitElement, TemplateResult } from "lit";
-import { customElement, property } from "lit/decorators.js";
-export { StatsProcess } from "./StatsProcess";
-import parsePrometheusTextFormat from "parse-prometheus-text-format";
-import debug from "debug";
-import { clamp } from "../web/floating_color_picker";
-const log = debug("home");
-
-interface Value {
-  labels: { string: string };
-  value?: string;
-  count?: number;
-  sum?: number;
-  buckets?: { [value: string]: string };
-}
-interface Metric {
-  name: string;
-  help: string;
-  type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED";
-  metrics: Value[];
-}
-type Metrics = Metric[];
-
-function nonBoring(m: Metric) {
-  return (
-    !m.name.endsWith("_created") && //
-    !m.name.startsWith("python_gc_") &&
-    m.name != "python_info" &&
-    m.name != "process_max_fds" &&
-    m.name != "process_virtual_memory_bytes" &&
-    m.name != "process_resident_memory_bytes" &&
-    m.name != "process_start_time_seconds" &&
-    m.name != "process_cpu_seconds_total"
-  );
-}
-
-@customElement("stats-line")
-export class StatsLine extends LitElement {
-  @property() name = "?";
-  @property() stats: Metrics = [];
-
-  prevCpuNow = 0;
-  prevCpuTotal = 0;
-  @property() cpu = 0;
-  @property() mem = 0;
-
-  updated(changedProperties: any) {
-    changedProperties.forEach((oldValue: any, propName: string) => {
-      if (propName == "name") {
-        const reload = () => {
-          fetch(this.name + "/metrics").then((resp) => {
-            if (resp.ok) {
-              resp
-                .text()
-                .then((msg) => {
-                  this.stats = parsePrometheusTextFormat(msg) as Metrics;
-                  this.extractProcessStats(this.stats);
-                  setTimeout(reload, 1000);
-                })
-                .catch((err) => {
-                  log(`${this.name} failing`, err)
-                  setTimeout(reload, 1000);
-                });
-            } else {
-              if (resp.status == 502) {
-                setTimeout(reload, 5000);
-              }
-              // 404: likely not mapped to a responding server
-            }
-          });
-        };
-        reload();
-      }
-    });
-  }
-  extractProcessStats(stats: Metrics) {
-    stats.forEach((row: Metric) => {
-      if (row.name == "process_resident_memory_bytes") {
-        this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024;
-      }
-      if (row.name == "process_cpu_seconds_total") {
-        const now = Date.now() / 1000;
-        const cpuSecondsTotal = parseFloat(row.metrics[0].value!);
-        this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow);
-        this.prevCpuTotal = cpuSecondsTotal;
-        this.prevCpuNow = now;
-      }
-    });
-  }
-
-  static styles = [
-    css`
-      :host {
-        border: 2px solid #46a79f;
-        display: inline-block;
-      }
-      table {
-        border-collapse: collapse;
-        background: #000;
-        color: #ccc;
-        font-family: sans-serif;
-      }
-      th,
-      td {
-        outline: 1px solid #000;
-      }
-      th {
-        padding: 2px 4px;
-        background: #2f2f2f;
-        text-align: left;
-      }
-      td {
-        padding: 0;
-        vertical-align: top;
-        text-align: center;
-      }
-      td.val {
-        padding: 2px 4px;
-        background: #3b5651;
-      }
-      .recents {
-        display: flex;
-        align-items: flex-end;
-        height: 30px;
-      }
-      .recents > div {
-        width: 3px;
-        background: red;
-        border-right: 1px solid black;
-      }
-      .bigInt {
-        min-width: 6em;
-      }
-    `,
-  ];
-
-  tdWrap(content: TemplateResult): TemplateResult {
-    return html`<td>${content}</td>`;
-  }
-
-  recents(d: any, path: string[]): TemplateResult {
-    const hi = Math.max.apply(null, d.recents);
-    const scl = 30 / hi;
-
-    const bar = (y: number) => {
-      let color;
-      if (y < d.average) {
-        color = "#6a6aff";
-      } else {
-        color = "#d09e4c";
-      }
-      return html`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`;
-    };
-    return html`<td>
-      <div class="recents">${d.recents.map(bar)}</div>
-      <div>avg=${d.average.toPrecision(3)}</div>
-    </td>`;
-  }
-
-  table(d: Metrics, path: string[]): TemplateResult {
-    const byName = new Map<string, Metric>();
-    d.forEach((row) => {
-      byName.set(row.name, row);
-    });
-    let cols = d.map((row) => row.name);
-    cols.sort();
-
-    if (path.length == 0) {
-      ["webServer", "process"].forEach((earlyKey) => {
-        let i = cols.indexOf(earlyKey);
-        if (i != -1) {
-          cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1));
-        }
-      });
-    }
-
-    const th = (col: string): TemplateResult => {
-      return html`<th>${col}</th>`;
-    };
-    const td = (col: string): TemplateResult => {
-      const cell = byName.get(col)!;
-      return html`${this.drawLevel(cell, path.concat(col))}`;
-    };
-    return html` <table>
-      <tr>
-        ${cols.map(th)}
-      </tr>
-      <tr>
-        ${cols.map(td)}
-      </tr>
-    </table>`;
-  }
-
-  drawLevel(d: Metric, path: string[]) {
-    return html`[NEW ${JSON.stringify(d)} ${path}]`;
-  }
-
-
-  valueDisplay(m: Metric, v: Value): TemplateResult {
-    if (m.type == "GAUGE") {
-      return html`${v.value}`;
-    } else if (m.type == "COUNTER") {
-      return html`${v.value}`;
-    } else if (m.type == "HISTOGRAM") {
-      return this.histoDisplay(v.buckets!);
-    } else if (m.type == "UNTYPED") {
-      return html`${v.value}`;
-    } else if (m.type == "SUMMARY") {
-      if (!v.count) {
-        return html`err: summary without count`;
-      }
-      return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`;
-    } else {
-      throw m.type;
-    }
-  }
-
-  private histoDisplay(b: { [value: string]: string }) {
-    const lines: TemplateResult[] = [];
-    let firstLevel;
-    let lastLevel;
-    let prev = 0;
-
-    let maxDelta = 0;
-    for (let level in b) {
-      if (firstLevel === undefined) firstLevel = level;
-      lastLevel = level;
-      let count = parseFloat(b[level]);
-      let delta = count - prev;
-      prev = count;
-      if (delta > maxDelta) maxDelta = delta;
-    }
-    prev = 0;
-    const maxBarH = 30;
-    for (let level in b) {
-      let count = parseFloat(b[level]);
-      let delta = count - prev;
-      prev = count;
-      let levelf = parseFloat(level);
-      const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH);
-      lines.push(
-        html`<div
-          title="bucket=${level} count=${count}"
-          style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block"
-        ></div>`
-      );
-    }
-    return html`${firstLevel} ${lines} ${lastLevel}`;
-  }
-
-  tightLabel(labs: { [key: string]: string }): string {
-    const d: { [key: string]: string } = {}
-    for (let k in labs) {
-      if (k == 'app_name') continue;
-      if (k == 'output') continue;
-      if (k=='status_code'&&labs[k]=="200") continue;
-      d[k] = labs[k]
-    }
-    const ret = JSON.stringify(d)
-    return ret == "{}" ? "" : ret
-  }
-  tightMetric(name: string): string {
-    return name
-    .replace('starlette', '⭐')
-    .replace("_request" ,"_req")
-    .replace("_duration" ,"_dur")
-    .replace('_seconds', '_s')
-  }
-  render() {
-    const now = Date.now() / 1000;
-
-    const displayedStats = this.stats.filter(nonBoring);
-    return html`
-      <div>
-        <table>
-          ${displayedStats.map(
-      (row, rowNum) => html`
-              <tr>
-                <th>${this.tightMetric(row.name)}</th>
-                <td>
-                  <table>
-                    ${row.metrics.map(
-        (v) => html`
-                        <tr>
-                          <td>${this.tightLabel(v.labels)}</td>
-                          <td>${this.valueDisplay(row, v)}</td>
-                        </tr>
-                      `
-      )}
-                  </table>
-                </td>
-                ${rowNum == 0
-          ? html`
-                      <td rowspan="${displayedStats.length}">
-                        <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process>
-                      </td>
-                    `
-          : ""}
-              </tr>
-            `
-    )}
-        </table>
-      </div>
-    `;
-  }
-}
--- a/light9/homepage/StatsProcess.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-import { LitElement, html, css } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import debug from "debug";
-
-const log = debug("process");
-
-const remap = (x: number, lo: number, hi: number, outLo: number, outHi: number) => {
-  return outLo + (outHi - outLo) * Math.max(0, Math.min(1, (x - lo) / (hi - lo)));
-};
-
-@customElement("stats-process")
-export class StatsProcess extends LitElement {
-  // inspired by https://codepen.io/qiruiyin/pen/qOopQx
-  @property() cpu = 0; // process_cpu_seconds_total
-  @property() mem = 0; // process_resident_memory_bytes
-
-  w = 64;
-  h = 64;
-  revs = 0;
-  prev = 0;
-  canvas?: HTMLCanvasElement;
-  ctx?: CanvasRenderingContext2D;
-  connectedCallback() {
-    super.connectedCallback();
-    this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
-    this.prev = Date.now() / 1000;
-
-    var animate = () => {
-      requestAnimationFrame(animate);
-      this.redraw();
-    };
-    animate();
-  }
-  initCanvas(canvas: HTMLCanvasElement) {
-    if (!canvas) {
-      return;
-    }
-    this.canvas = canvas;
-    this.ctx = this.canvas.getContext("2d")!;
-
-    this.canvas.width = this.w;
-    this.canvas.height = this.h;
-  }
-  redraw() {
-    if (!this.ctx) {
-      this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
-    }
-    if (!this.ctx) return;
-
-    this.canvas!.setAttribute("title", 
-    `cpu ${new Number(this.cpu).toPrecision(3)}% mem ${new Number(this.mem).toPrecision(3)}MB`);
-
-    const now = Date.now() / 1000;
-    const ctx = this.ctx;
-    ctx.beginPath();
-    // wrong type of fade- never goes to 0
-    ctx.fillStyle = "#00000003";
-    ctx.fillRect(0, 0, this.w, this.h);
-    const dt = now - this.prev;
-    this.prev = now;
-
-    const size = remap(this.mem.valueOf() / 1024 / 1024, /*in*/ 20, 80, /*out*/ 3, 30);
-    this.revs += dt * remap(this.cpu.valueOf(), /*in*/ 0, 100, /*out*/ 4, 120);
-    const rad = remap(size, /*in*/ 3, 30, /*out*/ 14, 5);
-
-    var x = this.w / 2 + rad * Math.cos(this.revs / 6.28),
-      y = this.h / 2 + rad * Math.sin(this.revs / 6.28);
-
-    ctx.save();
-    ctx.beginPath();
-    ctx.fillStyle = "hsl(194, 100%, 42%)";
-    ctx.arc(x, y, size, 0, 2 * Math.PI);
-    ctx.fill();
-    ctx.restore();
-  }
-
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-        width: 64px;
-        height: 64px;
-      }
-    `,
-  ];
-
-  render() {
-    return html`<canvas></canvas>`;
-  }
-}
--- a/light9/homepage/write_config.py	Thu Jun 08 12:28:27 2023 -0700
+++ b/light9/homepage/write_config.py	Thu Jun 08 13:20:23 2023 -0700
@@ -74,7 +74,7 @@
         showPath = showconfig.showUri().split('/', 3)[-1]
         root = showconfig.root()[:-len(showPath)].decode('ascii')
         print(f'''
-  location /show {{
+  location /show/ {{
     root {root};
   }}
 
--- a/light9/live/Effect.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,277 +0,0 @@
-import debug from "debug";
-import { Literal, NamedNode, Quad, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
-import { some } from "underscore";
-import { Patch } from "../web/patch";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { shortShow } from "../web/show_specific";
-import { SubEvent } from "sub-events";
-
-// todo: Align these names with newtypes.py, which uses HexColor and VTUnion.
-type Color = string;
-export type ControlValue = number | Color | NamedNode;
-
-const log = debug("effect");
-
-function isUri(x: Term | number | string): x is NamedNode {
-  return typeof x == "object" && x.termType == "NamedNode";
-}
-
-// todo: eliminate this. address the scaling when we actually scale
-// stuff, instead of making a mess of every setting
-function valuePred(graph: SyncedGraph, attr: NamedNode): NamedNode {
-  const U = graph.U();
-  const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
-  if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
-    return U(":value");
-  } else {
-    return U(":value");
-  }
-}
-
-// also see resourcedisplay's version of this
-function effContext(graph: SyncedGraph, uri: NamedNode): NamedNode {
-  return graph.Uri(uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
-}
-
-export function newEffect(graph: SyncedGraph): NamedNode {
-  // wrong- this should be our editor's scratch effect, promoted to a
-  // real one when you name it.
-  const uri = graph.nextNumberedResource(graph.Uri("http://light9.bigasterisk.com/effect/effect"));
-
-  const effect = new Effect(graph, uri);
-  const U = graph.U();
-  const ctx = effContext(graph, uri);
-  const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => graph.Quad(s, p, o, ctx);
-
-  const addQuads = [
-    quad(uri, U("rdf:type"), U(":Effect")),
-    quad(uri, U("rdfs:label"), graph.Literal(uri.value.replace(/.*\//, ""))),
-    quad(uri, U(":publishAttr"), U(":strength")),
-    quad(uri, U(":effectFunction"), U(":effectFunction/scale")),
-  ];
-  const patch = new Patch([], addQuads);
-  log("init new effect", patch);
-  graph.applyAndSendPatch(patch);
-
-  return effect.uri;
-}
-
-// effect settings data; r/w sync with the graph
-export class Effect {
-  // :effect1 a Effect; :setting ?eset . ?eset :effectAttr :deviceSettings; :value ?dset . ?dset :device ..
-  private eset?: NamedNode;
-  private dsettings: Array<{ dset: NamedNode; device: NamedNode; deviceAttr: NamedNode; value: ControlValue }> = [];
-
-  private ctxForEffect: NamedNode;
-  settingsChanged: SubEvent<void> = new SubEvent();
-
-  constructor(public graph: SyncedGraph, public uri: NamedNode) {
-    this.ctxForEffect = effContext(this.graph, this.uri);
-    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
-  }
-
-  private getExistingEset(): NamedNode | null {
-    const U = this.graph.U();
-    for (let eset of this.graph.objects(this.uri, U(":setting"))) {
-      if (this.graph.uriValue(eset as Quad_Subject, U(":effectAttr")).equals(U(":deviceSettings"))) {
-        return eset as NamedNode;
-      }
-    }
-    return null;
-  }
-  private getExistingEsetValueNode(): NamedNode | null {
-    const U = this.graph.U();
-    const eset = this.getExistingEset();
-    if (eset === null) return null;
-    try {
-      return this.graph.uriValue(eset, U(":value"));
-    } catch (e) {
-      return null;
-    }
-  }
-  private patchForANewEset(): { p: Patch; eset: NamedNode } {
-    const U = this.graph.U();
-    const eset = this.graph.nextNumberedResource(U(":e_set"));
-    return {
-      eset: eset,
-      p: new Patch(
-        [],
-        [
-          //
-          new Quad(this.uri, U(":setting"), eset, this.ctxForEffect),
-          new Quad(eset, U(":effectAttr"), U(":deviceSettings"), this.ctxForEffect),
-        ]
-      ),
-    };
-  }
-
-  private rebuildSettingsFromGraph(patch?: Patch) {
-    const U = this.graph.U();
-
-    log("syncFromGraph", this.uri);
-
-    // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
-    const newSettings = [];
-
-    const deviceSettingsNode = this.getExistingEsetValueNode();
-    if (deviceSettingsNode !== null) {
-      for (let dset of Array.from(this.graph.objects(deviceSettingsNode, U(":setting"))) as NamedNode[]) {
-        //   // log(`  setting ${setting.value}`);
-        //   if (!isUri(dset)) throw new Error();
-        let value: ControlValue;
-        const device = this.graph.uriValue(dset, U(":device"));
-        const deviceAttr = this.graph.uriValue(dset, U(":deviceAttr"));
-
-        const pred = valuePred(this.graph, deviceAttr);
-        try {
-          value = this.graph.uriValue(dset, pred);
-          if (!(value as NamedNode).id.match(/^http/)) {
-            throw new Error("not uri");
-          }
-        } catch (error) {
-          try {
-            value = this.graph.floatValue(dset, pred);
-          } catch (error1) {
-            value = this.graph.stringValue(dset, pred); // this may find multi values and throw
-          }
-        }
-        //   log(`change: graph contains ${deviceAttr.value} ${value}`);
-
-        newSettings.push({ dset, device, deviceAttr, value });
-      }
-    }
-    this.dsettings = newSettings;
-    log(`settings is rebuilt to length ${this.dsettings.length}`);
-    this.settingsChanged.emit(); // maybe one emitter per dev+attr?
-    // this.onValuesChanged();
-  }
-
-  currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
-    for (let s of this.dsettings) {
-      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
-        return s.value;
-      }
-    }
-    return null;
-  }
-
-  // change this object now, but return the patch to be applied to the graph so it can be coalesced.
-  edit(device: NamedNode, deviceAttr: NamedNode, newValue: ControlValue | null): Patch {
-    log(`edit: value=${newValue}`);
-    let existingSetting: NamedNode | null = null;
-    let result = new Patch([], []);
-
-    for (let s of this.dsettings) {
-      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
-        if (existingSetting !== null) {
-          // this is corrupt. There was only supposed to be one setting per (dev,attr) pair. But we can fix it because we're going to update existingSetting to the user's requested value.
-          log(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value} - deleting ${s.dset}`);
-          result = result.update(this.removeEffectSetting(s.dset));
-        }
-        existingSetting = s.dset;
-      }
-    }
-
-    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
-      if (existingSetting === null) {
-        result = result.update(this.addEffectSetting(device, deviceAttr, newValue));
-      } else {
-        result = result.update(this.patchExistingDevSetting(existingSetting, deviceAttr, newValue));
-      }
-    } else {
-      if (existingSetting !== null) {
-        result = result.update(this.removeEffectSetting(existingSetting));
-      }
-    }
-    return result;
-  }
-
-  shouldBeStored(deviceAttr: NamedNode, value: ControlValue | null): boolean {
-    // this is a bug for zoom=0, since collector will default it to
-    // stick at the last setting if we don't explicitly send the
-    // 0. rx/ry similar though not the exact same deal because of
-    // their remap.
-    return value != null && value !== 0 && value !== "#000000";
-  }
-
-  private addEffectSetting(device: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
-    log("  _addEffectSetting", deviceAttr.value, value);
-    const U = (x: string) => this.graph.Uri(x);
-    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
-
-    let patch = new Patch([], []);
-
-    let eset = this.getExistingEset();
-    if (eset === null) {
-      const ret = this.patchForANewEset();
-      patch = patch.update(ret.p);
-      eset = ret.eset;
-    }
-
-    let dsValue;
-    try {
-      dsValue = this.graph.uriValue(eset, U(":value"));
-    } catch (e) {
-      dsValue = this.graph.nextNumberedResource(U(":ds_val"));
-      patch = patch.update(new Patch([], [quad(eset, U(":value"), dsValue)]));
-    }
-
-    const dset = this.graph.nextNumberedResource(this.uri.value + "_set");
-
-    patch = patch.update(
-      new Patch(
-        [],
-        [
-          quad(dsValue, U(":setting"), dset),
-          quad(dset, U(":device"), device),
-          quad(dset, U(":deviceAttr"), deviceAttr),
-          quad(dset, valuePred(this.graph, deviceAttr), this.nodeForValue(value)),
-        ]
-      )
-    );
-    log("  save", patch);
-    this.dsettings.push({ dset, device, deviceAttr, value });
-    return patch;
-  }
-
-  private patchExistingDevSetting(devSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
-    log("  patch existing", devSetting.value);
-    return this.graph.getObjectPatch(
-      devSetting, //
-      valuePred(this.graph, deviceAttr),
-      this.nodeForValue(value),
-      this.ctxForEffect
-    );
-  }
-
-  private removeEffectSetting(effectSetting: NamedNode): Patch {
-    const U = (x: string) => this.graph.Uri(x);
-    log("  _removeEffectSetting", effectSetting.value);
-
-    const eset = this.getExistingEset();
-    if (eset === null) throw "unexpected";
-    const dsValue = this.graph.uriValue(eset, U(":value"));
-    if (dsValue === null) throw "unexpected";
-    const toDel = [this.graph.Quad(dsValue, U(":setting"), effectSetting, this.ctxForEffect)];
-    for (let q of this.graph.subjectStatements(effectSetting)) {
-      toDel.push(q);
-    }
-    return new Patch(toDel, []);
-  }
-
-  clearAllSettings() {
-    for (let s of this.dsettings) {
-      this.graph.applyAndSendPatch(this.removeEffectSetting(s.dset));
-    }
-  }
-
-  private nodeForValue(value: ControlValue): NamedNode | Literal {
-    if (value === null) {
-      throw new Error("no value");
-    }
-    if (isUri(value)) {
-      return value;
-    }
-    return this.graph.prettyLiteral(value);
-  }
-}
--- a/light9/live/Light9AttrControl.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,195 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property, state } from "lit/decorators.js";
-import { Literal, NamedNode } from "n3";
-import { SubEvent } from "sub-events";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { ControlValue, Effect } from "./Effect";
-import { DeviceAttrRow } from "./Light9DeviceControl";
-export { Slider } from "@material/mwc-slider";
-export { Light9ColorPicker } from "../web/light9-color-picker";
-export { Light9Listbox } from "./Light9Listbox";
-const log = debug("settings.dev.attr");
-
-type DataTypeNames = "scalar" | "color" | "choice";
-const makeType = (d: DataTypeNames) => new NamedNode(`http://light9.bigasterisk.com/${d}`);
-
-// UI for one device attr (of any type).
-@customElement("light9-attr-control")
-export class Light9AttrControl extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      #colorControls {
-        display: flex;
-        align-items: center;
-      }
-      #colorControls > * {
-        margin: 0 3px;
-      }
-      :host {
-      }
-      mwc-slider {
-        width: 250px;
-      }
-    `,
-  ];
-
-  @property() deviceAttrRow: DeviceAttrRow | null = null;
-  @state() dataType: DataTypeNames = "scalar";
-  @property() effect: Effect | null = null;
-  @property() enableChange: boolean = false;
-  @property() value: ControlValue | null = null; // e.g. color string
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      if (this.deviceAttrRow === null) throw new Error();
-    });
-  }
-
-  connectedCallback(): void {
-    super.connectedCallback();
-    setTimeout(() => {
-      // only needed once per page layout
-      this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
-    }, 1);
-  }
-
-  render() {
-    if (this.deviceAttrRow === null) throw new Error();
-    if (this.dataType == "scalar") {
-      const v = this.value || 0;
-      return html`<mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onValueInput}></mwc-slider> `;
-    } else if ((this.dataType = "color")) {
-      const v = this.value || "#000";
-      return html`
-        <div id="colorControls">
-          <button @click=${this.goBlack}>0.0</button>
-          <light9-color-picker .color=${v} @input=${this.onValueInput}></light9-color-picker>
-        </div>
-      `;
-    } else if (this.dataType == "choice") {
-      return html`<light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.value}> </light9-listbox> `;
-    }
-  }
-
-  updated(changedProperties: PropertyValues<this>) {
-    super.updated(changedProperties);
-
-    if (changedProperties.has("deviceAttrRow")) {
-      this.onDeviceAttrRowProperty();
-    }
-    if (changedProperties.has("effect")) {
-      this.onEffectProperty();
-    }
-    if (changedProperties.has("value")) {
-      this.onValueProperty();
-    }
-  }
-
-  private onValueProperty() {
-    if (this.deviceAttrRow === null) throw new Error();
-    if (!this.graph) {
-      log('ignoring value change- no graph yet')
-      return;
-    }
-    if (this.effect === null) {
-      this.value = null;
-    } else {
-      const p = this.effect.edit(
-        //
-        this.deviceAttrRow.device,
-        this.deviceAttrRow.uri,
-        this.value
-      );
-      if (!p.isEmpty()) {
-        log("Effect told us to graph.patch this:\n", p.dump());
-        this.graph.applyAndSendPatch(p);
-      }
-    }
-  }
-
-  private onEffectProperty() {
-    if (this.effect === null) {
-      log('no effect obj yet')
-      return;
-    }
-    // effect will read graph changes on its own, but emit an event when it does
-    this.effect.settingsChanged.subscribe(() => {
-      this.effectSettingsChanged();
-    });
-    this.effectSettingsChanged();
-  }
-
-  private effectSettingsChanged() {
-    // something in the settings graph is new
-    if (this.deviceAttrRow === null) throw new Error();
-    if (this.effect === null) throw new Error();
-    // log("graph->ui on ", this.deviceAttrRow.device, this.deviceAttrRow.uri);
-    const v = this.effect.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
-    this.onGraphValueChanged(v);
-  }
-
-  private onDeviceAttrRowProperty() {
-    if (this.deviceAttrRow === null) throw new Error();
-    const d = this.deviceAttrRow.dataType;
-    if (d.equals(makeType("scalar"))) {
-      this.dataType = "scalar";
-    } else if (d.equals(makeType("color"))) {
-      this.dataType = "color";
-    } else if (d.equals(makeType("choice"))) {
-      this.dataType = "choice";
-    }
-  }
-
-  onValueInput(ev: CustomEvent) {
-    if (ev.detail === undefined) {
-      // not sure what this is, but it seems to be followed by good events
-      return;
-    }
-    // log(ev.type, ev.detail.value);
-    this.value = ev.detail.value;
-    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
-  }
-
-  onGraphValueChanged(v: ControlValue | null) {
-    if (this.deviceAttrRow === null) throw new Error();
-    // log("change: control must display", v, "for", this.deviceAttrRow.device.value, this.deviceAttrRow.uri.value);
-    // this.enableChange = false;
-    if (this.dataType == "scalar") {
-      if (v !== null) {
-        this.value = v;
-      } else {
-        this.value = 0;
-      }
-    } else if (this.dataType == "color") {
-      this.value = v;
-    }
-  }
-
-  goBlack() {
-    this.value = "#000000";
-  }
-
-  onChoice(value: any) {
-    // if (value != null) {
-    //   value = this.graph.Uri(value);
-    // } else {
-    //   value = null;
-    // }
-  }
-
-  onChange(value: any) {
-    // if (typeof value === "number" && isNaN(value)) {
-    //   return;
-    // } // let onChoice do it
-    // //log('change: control tells graph', @deviceAttrRow.uri.value, value)
-    // if (value === undefined) {
-    //   value = null;
-    // }
-  }
-}
--- a/light9/live/Light9DeviceControl.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,210 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { unique } from "underscore";
-import { Patch } from "../web/patch";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { Choice } from "./Light9Listbox";
-import { Light9AttrControl } from "./Light9AttrControl";
-import { Effect } from "./Effect";
-export { ResourceDisplay } from "../web/ResourceDisplay";
-export { Light9AttrControl };
-const log = debug("settings.dev");
-
-export interface DeviceAttrRow {
-  uri: NamedNode; //devattr
-  device: NamedNode;
-  attrClasses: string; // the css kind
-  dataType: NamedNode;
-  choices: Choice[];
-  // choiceSize: number;
-  // max: number;
-}
-
-// Widgets for one device with multiple Light9LiveControl rows for the attr(s).
-@customElement("light9-device-control")
-export class Light9DeviceControl extends LitElement {
-  graph!: SyncedGraph;
-  static styles = [
-    css`
-      :host {
-        display: inline-block;
-      }
-      .device {
-        border: 2px solid #151e2d;
-        margin: 4px;
-        padding: 1px;
-        background: #171717; /* deviceClass gradient added later */
-        break-inside: avoid-column;
-        width: 335px;
-      }
-      .deviceAttr {
-        border-top: 1px solid #272727;
-        padding-bottom: 2px;
-        display: flex;
-      }
-      .deviceAttr > span {
-      }
-      .deviceAttr > light9-live-control {
-        flex-grow: 1;
-      }
-      h2 {
-        font-size: 110%;
-        padding: 4px;
-        margin-top: 0;
-        margin-bottom: 0;
-      }
-      .device,
-      h2 {
-        border-top-right-radius: 15px;
-      }
-
-      #mainLabel {
-        font-size: 120%;
-        color: #9ab8fd;
-        text-decoration: initial;
-      }
-      .device.selected h2 {
-        outline: 3px solid #ffff0047;
-      }
-      .deviceAttr.selected {
-        background: #cada1829;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <div class="device ${this.devClasses}">
-        <h2 style="${this._bgStyle(this.deviceClass)}" @click=${this.onClick}>
-          <resource-display id="mainLabel" .uri="${this.uri}"></resource-display>
-          a <resource-display minor .uri="${this.deviceClass}"></resource-display>
-        </h2>
-
-        ${this.deviceAttrs.map(
-          (dattr: DeviceAttrRow) => html`
-            <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
-              <span>
-                attr
-                <resource-display minor .uri=${dattr.uri}></resource-display>
-              </span>
-              <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}>
-              </light9-attr-control>
-            </div>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  @property() uri!: NamedNode;
-  @property() effect!: Effect;
-
-  @property() devClasses: string = ""; // the css kind
-  @property() deviceAttrs: DeviceAttrRow[] = [];
-  @property() deviceClass: NamedNode | null = null;
-  @property() selectedAttrs: Set<NamedNode> = new Set();
-
-  constructor() {
-    super();
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
-    });
-    this.selectedAttrs = new Set();
-  }
-
-  _bgStyle(deviceClass: NamedNode | null): string {
-    if (!deviceClass) return "";
-    let hash = 0;
-    const u = deviceClass.value;
-    for (let i = u.length - 10; i < u.length; i++) {
-      hash += u.charCodeAt(i);
-    }
-    const hue = (hash * 8) % 360;
-    const accent = `hsl(${hue}, 49%, 22%)`;
-    return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
-  }
-
-  setDeviceSelected(isSel: any) {
-    this.devClasses = isSel ? "selected" : "";
-  }
-
-  setAttrSelected(devAttr: NamedNode, isSel: boolean) {
-    if (isSel) {
-      this.selectedAttrs.add(devAttr);
-    } else {
-      this.selectedAttrs.delete(devAttr);
-    }
-  }
-
-  syncDeviceAttrsFromGraph(patch?: Patch) {
-    const U = this.graph.U();
-    if (patch && !patch.containsAnyPreds([U("rdf:type"), U(":deviceAttr"), U(":dataType"), U(":choice")])) {
-      return;
-    }
-    try {
-      this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
-    } catch (e) {
-      // what's likely is we're going through a graph reload and the graph
-      // is gone but the controls remain
-    }
-    this.deviceAttrs = [];
-    Array.from(unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(":deviceAttr"))))).map((da: NamedNode) =>
-      this.deviceAttrs.push(this.attrRow(da))
-    );
-    this.requestUpdate();
-  }
-
-  attrRow(devAttr: NamedNode): DeviceAttrRow {
-    let x: NamedNode;
-    const U = (x: string) => this.graph.Uri(x);
-    const dataType = this.graph.uriValue(devAttr, U(":dataType"));
-    const daRow = {
-      uri: devAttr,
-      device: this.uri,
-      dataType,
-      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
-      choices: [] as Choice[],
-      choiceSize: 0,
-      max: 1,
-    };
-     if (dataType.equals(U(":choice"))) {
-      const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(":choice")));
-      daRow.choices = (() => {
-        const result = [];
-        for (x of Array.from(choiceUris)) {
-          result.push({ uri: x.value, label: this.graph.labelOrTail(x) });
-        }
-        return result;
-      })();
-      daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
-    } else {
-      daRow.max = 1;
-      if (dataType.equals(U(":angle"))) {
-        // varies
-        daRow.max = 1;
-      }
-    }
-    return daRow;
-  }
-
-  clear() {
-    // why can't we just set their values ? what's diff about
-    // the clear state, and should it be represented with `null` value?
-    throw new Error();
-    // Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
-  }
-
-  onClick(ev: any) {
-    log("click", this.uri);
-    // select, etc
-  }
-
-  onAttrClick(ev: { model: { dattr: { uri: any } } }) {
-    log("attr click", this.uri, ev.model.dattr.uri);
-    // select
-  }
-}
--- a/light9/live/Light9DeviceSettings.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,153 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-import { NamedNode } from "n3";
-import { sortBy, uniq } from "underscore";
-import { Patch } from "../web/patch";
-import { getTopGraph } from "../web/RdfdbSyncedGraph";
-import { SyncedGraph } from "../web/SyncedGraph";
-import { Effect, newEffect } from "./Effect";
-export { EditChoice } from "../web/EditChoice";
-export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
-const log = debug("settings");
-
-@customElement("light9-device-settings")
-export class Light9DeviceSettings extends LitElement {
-  graph!: SyncedGraph;
-
-  static styles = [
-    css`
-      :host {
-        display: flex;
-        flex-direction: column;
-      }
-      #preview {
-        width: 100%;
-      }
-      #deviceControls {
-        flex-grow: 1;
-        position: relative;
-        width: 100%;
-        overflow-y: auto;
-      }
-
-      light9-device-control > div {
-        break-inside: avoid-column;
-      }
-      light9-device-control {
-        vertical-align: top;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <rdfdb-synced-graph></rdfdb-synced-graph>
-
-      <h1>effect DeviceSettings</h1>
-
-      <div id="save">
-        <div>
-          <button @click=${this.newEffect}>New effect</button>
-          <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2} rename></edit-choice>
-          <button @click=${this.clearAll}>clear settings in this effect</button>
-        </div>
-      </div>
-
-      <div id="deviceControls">
-        ${this.devices.map(
-          (device: NamedNode) => html`
-            <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control>
-          `
-        )}
-      </div>
-    `;
-  }
-
-  devices: Array<NamedNode> = [];
-  @property() currentEffect: Effect | null = null;
-  okToWriteUrl: boolean = false;
-
-  constructor() {
-    super();
-
-    getTopGraph().then((g) => {
-      this.graph = g;
-      this.graph.runHandler(this.compile.bind(this), "findDevices");
-      this.setEffectFromUrl();
-    });
-  }
-
-  onEffectChoice2(ev: CustomEvent) {
-    const uri = ev.detail.newValue as NamedNode;
-    this.setCurrentEffect(uri);
-  }
-  setCurrentEffect(uri: NamedNode) {
-    if (uri === null) {
-      this.currentEffect = null;
-      // todo: wipe the UI settings
-    } else {
-      this.currentEffect = new Effect(this.graph, uri);
-    }
-  }
-
-  updated(changedProperties: PropertyValues<this>) {
-    log("ctls udpated", changedProperties);
-    if (changedProperties.has("currentEffect")) {
-      log(`effectChoice to ${this.currentEffect?.uri?.value}`);
-      this.writeToUrl(this.currentEffect?.uri);
-    }
-    // this.graphToControls?.debugDump();
-  }
-
-  // Note that this doesn't fetch setting values, so it only should get rerun
-  // upon (rarer) changes to the devices etc. todo: make that be true
-  private compile(patch?: Patch) {
-    const U = this.graph.U();
-    // if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
-    //   return;
-    // }
-
-    this.devices = [];
-    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
-    log(`found ${classes.length} device classes`);
-    uniq(sortBy(classes, "value"), true).forEach((dc) => {
-      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
-        this.devices.push(dev as NamedNode);
-      });
-    });
-    this.requestUpdate();
-  }
-
-  setEffectFromUrl() {
-    // not a continuous bidi link between url and effect; it only reads
-    // the url when the page loads.
-    const effect = new URL(window.location.href).searchParams.get("effect");
-    if (effect != null) {
-      this.currentEffect = new Effect(this.graph, this.graph.Uri(effect));
-    }
-    this.okToWriteUrl = true;
-  }
-
-  writeToUrl(effect: NamedNode | undefined) {
-    const effectStr = effect ? this.graph.shorten(effect) : "";
-    if (!this.okToWriteUrl) {
-      return;
-    }
-    const u = new URL(window.location.href);
-    if ((u.searchParams.get("effect") || "") === effectStr) {
-      return;
-    }
-    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
-    window.history.replaceState({}, "", u.href);
-    log("wrote new url", u.href);
-  }
-
-  newEffect() {
-    this.setCurrentEffect(newEffect(this.graph));
-  }
-
-  clearAll() {
-    this.currentEffect?.clearAllSettings()
-  }
-}
--- a/light9/live/Light9Listbox.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,76 +0,0 @@
-import debug from "debug";
-import { css, html, LitElement, PropertyValues } from "lit";
-import { customElement, property } from "lit/decorators.js";
-const log = debug("listbox");
-export type Choice = { uri: string; label: string };
-
-@customElement("light9-listbox")
-export class Light9Listbox extends LitElement {
-  static styles = [
-    css`
-      paper-listbox {
-        --paper-listbox-background-color: none;
-        --paper-listbox-color: white;
-        --paper-listbox: {
-          /* measure biggest item? use flex for columns? */
-          column-width: 9em;
-        }
-      }
-      paper-item {
-        --paper-item-min-height: 0;
-        --paper-item: {
-          display: block;
-          border: 1px outset #0f440f;
-          margin: 0 1px 5px 0;
-          background: #0b1d0b;
-        }
-      }
-      paper-item.iron-selected {
-        background: #7b7b4a;
-      }
-    `,
-  ];
-
-  render() {
-    return html`
-      <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus">
-        <paper-item on-focus="selectOnFocus">None</paper-item>
-        <template is="dom-repeat" items="{{choices}}">
-          <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
-        </template>
-      </paper-listbox>
-    `;
-  }
-  @property() choices: Array<Choice> = [];
-  @property() value: String | null = null;
-
-  constructor() {
-    super();
-  }
-  selectOnFocus(ev) {
-    if (ev.target.uri === undefined) {
-      // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
-      //this.clear();
-      //return;
-    }
-    this.value = ev.target.uri;
-  }
-  updated(changedProperties: PropertyValues) {
-    if (changedProperties.has("value")) {
-      if (this.value === null) {
-        this.clear();
-      }
-    }
-  }
-  onValue(value: String | null) {
-    if (value === null) {
-      this.clear();
-    }
-  }
-  clear() {
-    this.querySelectorAll("paper-item").forEach(function (item) {
-      item.blur();
-    });
-    this.value = null;
-  }
-}
--- a/light9/live/README.md	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-This is an editor of :Effect resources, which have graphs like this:
-
-    <http://light9.bigasterisk.com/effect/effect43> a :Effect;
-    rdfs:label "effect43";
-    :publishAttr :strength;
-    :setting <http://light9.bigasterisk.com/effect/effect43_set0> .
-
-    <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 .
-
-# Objects
-
-SyncedGraph has the true data.
-
-Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page
-sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect
-(_probably_ at their zero position, but this is not always true) have a null value.
-
-GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them.
-
-We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no
-:setting for that device+attribute
-
-SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then
-the Effect would live as long as the page too)
--- a/light9/live/index.html	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-<!DOCTYPE html>
-<html>
-  <head>
-    <title>device settings</title>
-    <meta charset="utf-8" />
-    <link rel="stylesheet" href="../style.css" />
-    <script type="module" src="./Light9DeviceSettings"></script>
-  </head>
-  <body>
-    <style>
-      body,
-      html {
-        margin: 0;
-      }
-      light9-device-settings {
-        position: absolute;
-        left: 2px;
-        top: 2px;
-        right: 8px;
-        bottom: 0;
-      }
-    </style>
-    <light9-device-settings></light9-device-settings>
-  </body>
-</html>
--- a/light9/live/vite.config.ts	Thu Jun 08 12:28:27 2023 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-import { defineConfig } from "vite";
-
-const servicePort = 8217;
-export default defineConfig({
-  base: "/live/",
-  root: "./light9/live",
-  publicDir: "../..",
-  server: {
-    host: "0.0.0.0",
-    strictPort: true,
-    port: servicePort + 100,
-    hmr: {
-      port: servicePort + 200,
-    },
-  },
-  clearScreen: false,
-  define: {
-    global: {},
-  },
-});
--- a/light9/web/RdfdbSyncedGraph.ts	Thu Jun 08 12:28:27 2023 -0700
+++ b/light9/web/RdfdbSyncedGraph.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -39,7 +39,7 @@
       ["xsd", "http://www.w3.org/2001/XMLSchema#"],
     ]);
     this.graph = new SyncedGraph(
-      this.testGraph ? "unused" : "/rdfdb/api/syncedGraph",
+      this.testGraph ? "unused" : "/service/rdfdb/syncedGraph",
       prefixes,
       (s: string) => {
         this.status = s;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/ascoltami/Light9AscoltamiUi.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,310 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import { NamedNode } from "n3";
+import Sylvester from "sylvester";
+import { Zoom } from "../light9-timeline-audio";
+import { PlainViewState } from "../Light9CursorCanvas";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { TimingUpdate } from "./main";
+import { showRoot } from "../show_specific";
+export { Light9TimelineAudio } from "../light9-timeline-audio";
+export { Light9CursorCanvas } from "../Light9CursorCanvas";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { ResourceDisplay } from "../ResourceDisplay";
+const $V = Sylvester.Vector.create;
+
+debug.enable("*");
+const log = debug("asco");
+
+function byId(id: string): HTMLElement {
+  return document.getElementById(id)!;
+}
+async function postJson(url: string, jsBody: Object) {
+  return fetch(url, {
+    method: "POST",
+    headers: { "Content-Type": "applcation/json" },
+    body: JSON.stringify(jsBody),
+  });
+}
+@customElement("light9-ascoltami-ui")
+export class Light9AscoltamiUi extends LitElement {
+  graph!: SyncedGraph;
+  times!: { intro: number; post: number };
+  @property() nextText: string = "";
+  @property() isPlaying: boolean = false;
+  @property() show: NamedNode | null = null;
+  @property() song: NamedNode | null = null;
+  @property() selectedSong: NamedNode | null = null;
+  @property() currentDuration: number = 0;
+  @property() zoom: Zoom;
+  @property() overviewZoom: Zoom;
+  @property() viewState: PlainViewState | null = null;
+  static styles = [
+    css`
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      .timeRow {
+        margin: 14px;
+        position: relative;
+      }
+      #overview {
+        height: 60px;
+      }
+      #zoomed {
+        margin-top: 40px;
+        height: 80px;
+      }
+      #cursor {
+        position: absolute;
+        left: 0;
+        top: 0;
+        width: 100%;
+        height: 100%;
+      }
+      #grow {
+        flex: 1 1 auto;
+        display: flex;
+      }
+      #grow > span {
+        display: flex;
+        position: relative;
+        width: 50%;
+      }
+      #playSelected {
+        height: 100px;
+      }
+      #songList {
+        overflow-y: scroll;
+        position: absolute;
+        left: 0;
+        top: 0;
+        right: 0;
+        bottom: 0;
+      }
+      #songList .row {
+        width: 60%;
+        min-height: 40px;
+        text-align: left;
+        position: relative;
+      }
+      #songList .row:nth-child(even) {
+        background: #333;
+      }
+      #songList .row:nth-child(odd) {
+        background: #444;
+      }
+      #songList button {
+        min-height: 40px;
+        margin-bottom: 10px;
+      }
+      #songList .row.playing {
+        box-shadow: 0 0 30px red;
+        background-color: #de5050;
+      }
+    `,
+  ];
+  render() {
+    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <link rel="stylesheet" href="../style.css" />
+
+      <!-- <h1>ascoltami <a href="metrics">[metrics]</a></h1> -->
+
+      <div id="grow">
+        <span>
+          <div id="songList">
+            <table>
+              ${this.songList.map(
+                (song) => html`
+                  <tr
+                    class="row ${classMap({
+                      playing: !!(this.song && song.equals(this.song)),
+                    })}"
+                  >
+                    <td><resource-display .uri=${song} noclick></resource-display></td>
+                    <td>
+                      <button @click=${this.onSelectSong.bind(this, song)}>
+                        <span>Select</span>
+                      </button>
+                    </td>
+                  </tr>
+                `
+              )}
+            </table>
+          </div> </span
+        ><span>
+          <div id="right">
+            <div>
+              Selected:
+              <resource-display .uri=${this.selectedSong}></resource-display>
+            </div>
+            <div>
+              <button id="playSelected" ?disabled=${this.selectedSong === null} @click=${this.onPlaySelected}>Play selected from start</button>
+            </div>
+          </div>
+        </span>
+      </div>
+
+      <div class="timeRow">
+        <div id="timeSlider"></div>
+        <light9-timeline-audio id="overview" .show=${this.show} .song=${this.song} .zoom=${this.overviewZoom}> </light9-timeline-audio>
+        <light9-timeline-audio id="zoomed" .show=${this.show} .song=${this.song} .zoom=${this.zoom}></light9-timeline-audio>
+        <light9-cursor-canvas id="cursor" .viewState=${this.viewState}></light9-cursor-canvas>
+      </div>
+
+      <div class="commands">
+        <button id="cmd-stop" @click=${this.onCmdStop} class="playMode ${classMap({ active: !this.isPlaying })}">
+          <strong>Stop</strong>
+          <div class="key">s</div>
+        </button>
+        <button id="cmd-play" @click=${this.onCmdPlay} class="playMode ${classMap({ active: this.isPlaying })}">
+          <strong>Play</strong>
+          <div class="key">p</div>
+        </button>
+        <button id="cmd-intro" @click=${this.onCmdIntro}>
+          <strong>Skip intro</strong>
+          <div class="key">i</div>
+        </button>
+        <button id="cmd-post" @click=${this.onCmdPost}>
+          <strong>Skip to Post</strong>
+          <div class="key">t</div>
+        </button>
+        <button id="cmd-go" @click=${this.onCmdGo}>
+          <strong>Go</strong>
+          <div class="key">g</div>
+          <div id="next">${this.nextText}</div>
+        </button>
+      </div>`;
+  }
+
+  onSelectSong(song: NamedNode, ev: MouseEvent) {
+    if (this.selectedSong && song.equals(this.selectedSong)) {
+      this.selectedSong = null;
+    } else {
+      this.selectedSong = song;
+    }
+  }
+  async onPlaySelected(ev: Event) {
+    if (!this.selectedSong) {
+      return;
+    }
+    await fetch("../service/ascoltami/song", { method: "POST", body: this.selectedSong.value });
+  }
+
+  onCmdStop(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", { pause: true });
+  }
+  onCmdPlay(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", { resume: true });
+  }
+  onCmdIntro(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", { t: this.times.intro, resume: true });
+  }
+  onCmdPost(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/time", {
+      t: this.currentDuration - this.times.post,
+      resume: true,
+    });
+  }
+  onCmdGo(ev?: MouseEvent): void {
+    postJson("../service/ascoltami/go", {});
+  }
+
+  bindKeys() {
+    document.addEventListener("keypress", (ev) => {
+      if (ev.which == 115) {
+        this.onCmdStop();
+        return false;
+      }
+      if (ev.which == 112) {
+        this.onCmdPlay();
+        return false;
+      }
+      if (ev.which == 105) {
+        this.onCmdIntro();
+        return false;
+      }
+      if (ev.which == 116) {
+        this.onCmdPost();
+        return false;
+      }
+
+      if (ev.key == "g") {
+        this.onCmdGo();
+        return false;
+      }
+      return true;
+    });
+  }
+
+  async musicSetup() {
+    // shoveled over from the vanillajs version
+    const config = await (await fetch("../service/ascoltami/config")).json();
+    this.show = new NamedNode(config.show);
+    this.times = config.times;
+    document.title = document.title.replace("{{host}}", config.host);
+    try {
+      const h1 = document.querySelector("h1")!;
+      h1.innerText = h1.innerText.replace("{{host}}", config.host);
+    } catch (e) {}
+
+    (window as any).finishOldStyleSetup(this.times, this.onOldStyleUpdate.bind(this));
+  }
+
+  onOldStyleUpdate(data: TimingUpdate) {
+    this.nextText = data.next;
+    this.isPlaying = data.playing;
+    this.currentDuration = data.duration;
+    this.song = new NamedNode(data.song);
+    this.overviewZoom = { duration: data.duration, t1: 0, t2: data.duration };
+    const t1 = data.t - 2,
+      t2 = data.t + 20;
+    this.zoom = { duration: data.duration, t1, t2 };
+    const timeRow = this.shadowRoot!.querySelector(".timeRow") as HTMLDivElement;
+    const w = timeRow.offsetWidth;
+    this.viewState = {
+      zoomSpec: { t1: () => t1, t2: () => t2 },
+      cursor: { t: () => data.t },
+      audioY: () => 0,
+      audioH: () => 60,
+      zoomedTimeY: () => 60,
+      zoomedTimeH: () => 40,
+      fullZoomX: (sec: number) => (sec / data.duration) * w,
+      zoomInX: (sec: number) => ((sec - t1) / (t2 - t1)) * w,
+      mouse: { pos: () => $V([0, 0]) },
+    };
+  }
+
+  @property() songList: NamedNode[] = [];
+  constructor() {
+    super();
+    this.bindKeys();
+    this.zoom = this.overviewZoom = { duration: null, t1: 0, t2: 1 };
+
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.musicSetup(); // async
+      this.graph.runHandler(this.graphChanged.bind(this), "loadsongs");
+    });
+  }
+  graphChanged() {
+    this.songList = [];
+    try {
+      const playList = this.graph.uriValue(
+        //
+        this.graph.Uri(showRoot),
+        this.graph.Uri(":playList")
+      );
+      log(playList);
+      this.songList = this.graph.items(playList) as NamedNode[];
+    } catch (e) {
+      log("no playlist yet");
+    }
+    log(this.songList.length);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/ascoltami/index.html	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>ascoltami on {{host}}</title>
+    <link rel="stylesheet" href="../style.css" />
+    <style>
+      #cmd-go {
+        min-width: 5em;
+      }
+      .song-name {
+        padding-left: 0.4em;
+      }
+      .dimStalled #currentTime {
+        font-size: 20px;
+        background: green;
+        color: black;
+        padding: 3px;
+      }
+      .dimStalled {
+        font-size: 90%;
+      }
+      body {
+        margin: 0;
+        padding: 0;
+        overflow: hidden;
+        min-height: 100vh;
+      }
+      #page {
+        width: 100%;
+        height: 100vh; /* my phone was losing the bottom :( */
+        display: flex;
+        flex-direction: column;
+      }
+      #page > div,
+      #page > p {
+        flex: 0 1 auto;
+        margin: 0;
+      }
+      light9-ascoltami-ui {
+        flex: 1 1 auto;
+      }
+    </style>
+    <meta
+      name="viewport"
+      content="user-scalable=no, width=device-width, initial-scale=.7"
+    />
+    <script type="module" src="./Light9AscoltamiUi"></script>
+  </head>
+  <body>
+    <div id="page">
+      <h1>ascoltami on {{host}}</h1>
+      <div class="songs" style="display: none"></div>
+
+      <div class="dimStalled">
+        <table>
+          <tr>
+            <td colspan="3">
+              <strong>Song:</strong> <span id="currentSong"></span>
+            </td>
+          </tr>
+          <tr>
+            <td><strong>Time:</strong> <span id="currentTime"></span></td>
+            <td><strong>Left:</strong> <span id="leftTime"></span></td>
+            <td>
+              <strong>Until autostop:</strong>
+              <span id="leftAutoStopTime"></span>
+            </td>
+          </tr>
+          <tr>
+            <td colspan="3">
+               <span id="states"></span>
+            </td>
+          </tr>
+        </table>
+      </div>
+
+      <hr />
+      <light9-ascoltami-ui></light9-ascoltami-ui>
+      <p><a href="">reload</a></p>
+    </div>
+    <script type="module" src="./main.ts"></script>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/ascoltami/main.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,100 @@
+function byId(id: string): HTMLElement {
+  return document.getElementById(id)!;
+}
+
+export interface TimingUpdate {
+  // GET /ascoltami/time response
+  duration: number;
+  next: string; // e.g. 'play'
+  playing: boolean;
+  song: string;
+  started: number; // unix sec
+  t: number; // seconds into song
+  state: { current: { name: string }; pending: { name: string } };
+}
+
+(window as any).finishOldStyleSetup = async (times: { intro: number; post: number }, timingUpdate: (data: TimingUpdate) => void) => {
+  let currentHighlightedSong = "";
+  // let lastPlaying = false;
+
+  
+  const events = new EventSource("../service/ascoltami/time/stream");
+  events.addEventListener("message", (m)=>{
+    const update = JSON.parse(m.data) as TimingUpdate
+    updateCurrent(update)
+    markUpdateTiming();
+  })
+
+  async function updateCurrent(data:TimingUpdate) {
+    byId("currentSong").innerText = data.song;
+    if (data.song != currentHighlightedSong) {
+      showCurrentSong(data.song);
+    }
+    byId("currentTime").innerText = data.t.toFixed(1);
+    byId("leftTime").innerText = (data.duration - data.t).toFixed(1);
+    byId("leftAutoStopTime").innerText = Math.max(0, data.duration - times.post - data.t).toFixed(1);
+    byId("states").innerText = JSON.stringify(data.state);
+    //   document.querySelector("#timeSlider").slider({ value: data.t, max: data.duration });
+    timingUpdate(data);
+  }
+  let recentUpdates: Array<number> = [];
+  function markUpdateTiming() {
+    recentUpdates.push(+new Date());
+    recentUpdates = recentUpdates.slice(Math.max(recentUpdates.length - 5, 0));
+  }
+
+  function refreshUpdateFreqs() {
+    if (recentUpdates.length > 1) {
+      if (+new Date() - recentUpdates[recentUpdates.length - 1] > 1000) {
+        byId("updateActual").innerText = "(stalled)";
+        return;
+      }
+
+      var avgMs = (recentUpdates[recentUpdates.length - 1] - recentUpdates[0]) / (recentUpdates.length - 1);
+      byId("updateActual").innerText = "" + Math.round(1000 / avgMs);
+    }
+  }
+  setInterval(refreshUpdateFreqs, 2000);
+
+  function showCurrentSong(uri: string) {
+    document.querySelectorAll(".songs div").forEach((row: Element, i: number) => {
+      if (row.querySelector("button")!.dataset.uri == uri) {
+        row.classList.add("currentSong");
+      } else {
+        row.classList.remove("currentSong");
+      }
+    });
+    currentHighlightedSong = uri;
+  }
+
+  const data = await (await fetch("api/songs")).json();
+  data.songs.forEach((song: { uri: string; label: string }) => {
+    const button = document.createElement("button");
+    // link is just for dragging, not clicking
+    const link = document.createElement("a");
+    const n = document.createElement("span");
+    n.classList.add("num");
+    n.innerText = song.label.slice(0, 2);
+    link.appendChild(n);
+
+    const sn = document.createElement("span");
+    sn.classList.add("song-name");
+    sn.innerText = song.label.slice(2).trim();
+    link.appendChild(sn);
+    link.setAttribute("href", song.uri);
+    link.addEventListener("click", (ev) => {
+      ev.stopPropagation();
+      button.click();
+    });
+    button.appendChild(link);
+    button.dataset.uri = song.uri;
+    button.addEventListener("click", async (ev) => {
+      await fetch("api/song", { method: "POST", body: song.uri });
+      showCurrentSong(song.uri);
+    });
+    const dv = document.createElement("div");
+    dv.appendChild(button);
+    document.querySelector(".songs")!.appendChild(dv);
+  });
+
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/collector/Light9CollectorDevice.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,75 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+export { ResourceDisplay } from "../../web/ResourceDisplay";
+
+const log = debug("device-el");
+
+@customElement("light9-collector-device")
+export class Light9CollectorDevice extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        break-inside: avoid-column;
+        font-size: 80%;
+      }
+      h3 {
+        margin-top: 12px;
+        margin-bottom: 0;
+      }
+      td {
+        white-space: nowrap;
+      }
+
+      td.nonzero {
+        background: #310202;
+        color: #e25757;
+      }
+      td.full {
+        background: #2b0000;
+        color: red;
+        font-weight: bold;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <h3><resource-display .uri=${this.uri}></resource-display></h3>
+      <table class="borders">
+        <tr>
+          <th>out attr</th>
+          <th>value</th>
+          <th>chan</th>
+        </tr>
+        ${this.attrs.map(
+          (item) => html`
+            <tr>
+              <td>${item.attr}</td>
+              <td class=${item.valClass}>${item.val} →</td>
+              <td>${item.chan}</td>
+            </tr>
+          `
+        )}
+      </table>
+    `;
+  }
+  @property({
+    converter: acceptStringOrUri(),
+  })
+  uri: NamedNode = new NamedNode("");
+  @property() attrs: Array<{ attr: string; valClass: string; val: string; chan: string }> = [];
+
+  setAttrs(attrs: any) {
+    this.attrs = attrs;
+    this.attrs.forEach(function (row: any) {
+      row.valClass = row.val == 255 ? "full" : row.val ? "nonzero" : "";
+    });
+  }
+}
+
+function acceptStringOrUri() {
+  return (s: string | null) => new NamedNode(s || "");
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/collector/Light9CollectorUi.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,91 @@
+import debug from "debug";
+import { html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import ReconnectingWebSocket from "reconnectingwebsocket";
+import { sortBy, uniq } from "underscore";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Light9CollectorDevice } from "./Light9CollectorDevice";
+export { RdfdbSyncedGraph } from "../RdfdbSyncedGraph";
+export { Light9CollectorDevice };
+
+debug.enable("*");
+const log = debug("collector");
+
+@customElement("light9-collector-ui")
+export class Light9CollectorUi extends LitElement {
+  graph!: SyncedGraph;
+  render() {
+    return html`<rdfdb-synced-graph></rdfdb-synced-graph>
+      <h1>Collector <a href="metrics">[metrics]</a></h1>
+
+      <h2>Devices</h2>
+      <div style="column-width: 11em">${this.devices.map((d) => html`<light9-collector-device .uri=${d}></light9-collector-device>`)}</div> `;
+  }
+
+  @property() devices: NamedNode[] = [];
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.findDevices.bind(this), "findDevices");
+    });
+
+    const ws = new ReconnectingWebSocket(location.href.replace("http", "ws") + "../service/collector/updates");
+    ws.addEventListener("message", (ev: any) => {
+      const outputAttrsSet = JSON.parse(ev.data).outputAttrsSet;
+      if (outputAttrsSet) {
+        this.updateDev(outputAttrsSet.dev, outputAttrsSet.attrs);
+      }
+    });
+  }
+
+  findDevices(patch?: Patch) {
+    const U = this.graph.U();
+
+    this.devices = [];
+    this.clearDeviceChildElementCache();
+    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
+    uniq(sortBy(classes, "value"), true).forEach((dc) => {
+      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
+        this.devices.push(dev as NamedNode);
+      });
+    });
+  }
+
+  deviceElements: Map<string, Light9CollectorDevice> = new Map();
+
+  clearDeviceChildElementCache() {
+    this.deviceElements = new Map();
+  }
+
+  findDeviceChildElement(uri: string): Light9CollectorDevice | undefined {
+    const known = this.deviceElements.get(uri);
+    if (known) {
+      return known;
+    }
+
+    for (const el of this.shadowRoot!.querySelectorAll("light9-collector-device")) {
+      const eld = el as Light9CollectorDevice;
+      if (eld.uri.value == uri) {
+        this.deviceElements.set(uri, eld);
+        return eld;
+      }
+    }
+
+    return undefined;
+  }
+
+  updateDev(uri: string, attrs: { attr: string; chan: string; val: string; valClass: string }[]) {
+    const el = this.findDeviceChildElement(uri);
+    if (!el) {
+      // unresolved race: updates come in before we have device elements to display them
+      setTimeout(() => this.updateDev(uri, attrs), 300);
+      return;
+    }
+    el.setAttrs(attrs);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/collector/index.html	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>collector</title>
+    <meta charset="utf-8" />
+
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="Light9CollectorUi"></script>
+
+    <style>
+      td {
+        white-space: nowrap;
+      }
+    </style>
+  </head>
+  <body>
+    <light9-collector-ui></light9-collector-ui>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/effects/Light9EffectListing.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,113 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { sortBy } from "underscore";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+export { ResourceDisplay } from "../ResourceDisplay";
+
+debug.enable("*");
+const log = debug("listing");
+
+@customElement("light9-effect-listing")
+export class Light9EffectListing extends LitElement {
+  render() {
+    return html`
+      <h1>Effects</h1>
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      ${this.effects.map((e: NamedNode) => html`<light9-effect-class .uri=${e}></light9-effect-class>`)}
+    `;
+  }
+  graph!: SyncedGraph;
+  effects: NamedNode[] = [];
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.getClasses.bind(this), "getClasses");
+    });
+  }
+
+  getClasses() {
+    const U = this.graph.U();
+    this.effects = this.graph.subjects(U("rdf:type"), U(":Effect")) as NamedNode[];
+    this.effects = sortBy(this.effects, (ec: NamedNode) => {
+      try {
+        return this.graph.stringValue(ec, U("rdfs:label"));
+      } catch (e) {
+        return ec.value;
+      }
+    });
+    this.requestUpdate();
+  }
+}
+
+@customElement("light9-effect-class")
+export class Light9EffectClass extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        padding: 5px;
+        border: 1px solid green;
+        background: #1e271e;
+        margin-bottom: 3px;
+      }
+      a {
+        color: #7992d0;
+        background: #00000859;
+        min-width: 4em;
+        min-height: 2em;
+        display: inline-block;
+        text-align: center;
+        vertical-align: middle;
+      }
+      resource-display {
+        min-width: 12em;
+        font-size: 180%;
+      }
+    `,
+  ];
+  render() {
+    if (!this.uri) {
+      return html`loading...`;
+    }
+    return html`
+      Effect
+      <resource-display .uri=${this.uri} rename></resource-display>
+      <a href="../live?effect=${this.uri.value}">Edit</a>
+      <iron-ajax id="songEffects" url="/effectEval/songEffects" method="POST" content-type="application/x-www-form-urlencoded"></iron-ajax>
+      <span style="float:right">
+        <button disabled @click=${this.onAdd}>Add to current song</button>
+        <button disabled @mousedown=${this.onMomentaryPress} @mouseup=${this.onMomentaryRelease}>Add momentary</button>
+      </span>
+    `;
+  }
+  graph!: SyncedGraph;
+  uri?: NamedNode;
+
+  onAdd() {
+    // this.$.songEffects.body = { drop: this.uri.value };
+    // this.$.songEffects.generateRequest();
+  }
+
+  onMomentaryPress() {
+    // this.$.songEffects.body = { drop: this.uri.value, event: "start" };
+    // this.lastPress = this.$.songEffects.generateRequest();
+    // return this.lastPress.completes.then((request: { response: { note: any } }) => {
+    //   return (this.lastMomentaryNote = request.response.note);
+    // });
+  }
+
+  onMomentaryRelease() {
+    // if (!this.lastMomentaryNote) {
+    //   return;
+    // }
+    // this.$.songEffects.body = { drop: this.uri.value, note: this.lastMomentaryNote };
+    // this.lastMomentaryNote = null;
+    // return this.$.songEffects.generateRequest();
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/effects/index.html	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,12 @@
+<!doctype html>
+<html>
+  <head>
+    <title>effect listing</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css">    
+    <script type="module" src="./Light9EffectListing"></script>
+  </head>
+  <body>
+    <light9-effect-listing></light9-effect-listing>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/Light9EffectFader.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,190 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { NamedNode, Quad } from "n3";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { showRoot } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+import { Patch } from "../patch";
+import { Literal } from "n3";
+export { Light9Fader } from "./Light9Fader";
+
+const log = debug("efffader")
+
+//////////////////////////////////////
+const RETURN_URI = new NamedNode("");
+const RETURN_FLOAT = 1;
+function get2Step<T extends NamedNode | number>(returnWhat: T, graph: SyncedGraph, subj1: NamedNode, pred1: NamedNode, pred2: NamedNode): T | undefined {
+  // ?subj1 ?pred1 ?x . ?x ?pred2 ?returned .
+  let x: NamedNode;
+  try {
+    x = graph.uriValue(subj1, pred1);
+  } catch (e) {
+    return undefined;
+  }
+  try {
+    if (typeof returnWhat === "object" && (returnWhat as NamedNode).termType == "NamedNode") {
+      return graph.uriValue(x, pred2) as T;
+    } else if (typeof returnWhat === "number") {
+      return graph.floatValue(x, pred2) as T;
+    }
+  } catch (e) {
+    return undefined;
+  }
+}
+function set2Step(
+  graph: SyncedGraph, //
+  subj1: NamedNode,
+  pred1: NamedNode,
+  baseName: string,
+  pred2: NamedNode,
+  newObjLiteral: Literal
+) { }
+
+function maybeUriValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): NamedNode | undefined {
+  try {
+    return graph.uriValue(s, p);
+  } catch (e) {
+    return undefined;
+  }
+}
+function maybeStringValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): string | undefined {
+  try {
+    return graph.stringValue(s, p);
+  } catch (e) {
+    return undefined;
+  }
+}
+function maybeFloatValue(graph: SyncedGraph, s: NamedNode, p: NamedNode): number | undefined {
+  try {
+    return graph.floatValue(s, p);
+  } catch (e) {
+    return undefined;
+  }
+}
+
+//////////////////////////////////////
+class EffectFader {
+  constructor(public uri: NamedNode) { }
+  column: string = "unset";
+  effect?: NamedNode;
+  effectAttr?: NamedNode; // :strength
+  setting?: NamedNode; // we assume fader always has exactly one setting
+  value?: number;
+}
+
+@customElement("light9-effect-fader")
+export class Light9EffectFader extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        border: 2px gray outset;
+        background: #272727;
+      }
+      light9-fader {
+        margin: 0px;
+        width: 100%;
+      }
+    `,
+  ];
+  render() {
+    if (this.conf === undefined || this.conf.value === undefined) {
+      return html`...`;
+    }
+    return html`
+      <div><resource-display .uri=${this.uri}></resource-display>
+      <light9-fader .value=${this.conf.value} @change=${this.onSliderInput}></light9-fader>
+      <div>${this.conf.value.toPrecision(3)}</div>
+      <div>effect <edit-choice nounlink .uri=${this.conf.effect} @edited=${this.onEffectChange}></edit-choice></div>
+      <div>attr <edit-choice nounlink .uri=${this.conf.effectAttr} @edited=${this.onEffectAttrChange}></edit-choice></div>
+    `;
+  }
+
+  graph?: SyncedGraph;
+  ctx: NamedNode = new NamedNode(showRoot + "/fade");
+  @property() uri!: NamedNode;
+  @state() conf?: EffectFader; // compiled from graph
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.compile.bind(this, this.graph), `fader config ${this.uri.value}`);
+    });
+  }
+
+  private compile(graph: SyncedGraph) {
+    const U = graph.U();
+    this.conf = undefined;
+
+    const conf = new EffectFader(this.uri);
+
+    if (!graph.contains(this.uri, U("rdf:type"), U(":Fader"))) {
+      // not loaded yet, perhaps
+      return;
+    }
+
+    conf.column = maybeStringValue(graph, this.uri, U(":column")) || "unset";
+    conf.effect = maybeUriValue(graph, this.uri, U(":effect"));
+    conf.effectAttr = get2Step(RETURN_URI, graph, this.uri, U(":setting"), U(":effectAttr"));
+
+    this.conf = conf;
+    graph.runHandler(this.compileValue.bind(this, graph, this.conf), `fader config.value ${this.uri.value}`);
+  }
+
+  private compileValue(graph: SyncedGraph, conf: EffectFader) {
+    // external graph change -> conf.value
+    const U = graph.U();
+    conf.value = get2Step(RETURN_FLOAT, graph, this.uri, U(":setting"), U(":value"));
+    // since conf attrs aren't watched as property:
+    this.requestUpdate()
+  }
+
+  onSliderInput(ev: CustomEvent) {
+    // slider user input -> graph
+    if (this.conf === undefined) return;
+    this.conf.value = ev.detail.value
+    this.writeValueToGraph()
+  }
+
+  writeValueToGraph() {
+    // this.value -> graph
+    if (this.graph === undefined) {
+      return;
+    }
+    const U = this.graph.U();
+    if (this.conf === undefined) {
+      return;
+    }
+    if (this.conf.value === undefined) {
+      log(`value of ${this.uri} is undefined`)
+      return;
+    }
+    log('writeValueToGraph', this.conf.value)
+    const valueTerm = this.graph.LiteralRoundedFloat(this.conf.value);
+    const settingNode = this.graph.uriValue(this.uri, U(":setting"));
+    this.graph.patchObject(settingNode, this.graph.Uri(":value"), valueTerm, this.ctx);
+
+  }
+
+  onEffectChange(ev: CustomEvent) {
+    if (this.graph === undefined) {
+      return;
+    }
+    const { newValue } = ev.detail;
+    this.graph.patchObject(this.uri, this.graph.Uri(":effect"), newValue, this.ctx);
+  }
+
+  onEffectAttrChange(ev: CustomEvent) {
+    if (this.graph === undefined) {
+      return;
+    }
+    // const { newValue } = ev.detail;
+    // if (this.setting === undefined) {
+    //   this.setting = this.graph.nextNumberedResource(this.graph.Uri(":fade_set"));
+    //   this.graph.patchObject(this.uri, this.graph.Uri(":setting"), this.setting, this.ctx);
+    // }
+    // this.graph.patchObject(this.setting, this.graph.Uri(":effectAttr"), newValue, this.ctx);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/Light9FadeUi.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,169 @@
+import debug from "debug";
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import * as N3 from "n3";
+import { NamedNode, Quad } from "n3";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { showRoot } from "../show_specific";
+import { SyncedGraph } from "../SyncedGraph";
+export { EditChoice } from "../EditChoice";
+export { Light9EffectFader } from "./Light9EffectFader";
+export { Light9Fader } from "./Light9Fader";
+
+debug.enable("*,autodep");
+const log = debug("fade");
+
+class FaderConfig {
+  constructor(public uri: NamedNode, public column: number) { }
+}
+
+class FadePage {
+  constructor(public uri: NamedNode) { }
+  faderConfigs: FaderConfig[] = [];
+}
+class FadePages {
+  pages: FadePage[] = [];
+}
+
+@customElement("light9-fade-ui")
+export class Light9FadeUi extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: block;
+        user-select: none; /* really this is only desirable during slider drag events */
+      }
+      .mappedToHw {
+        background: #393945;
+      }
+      #gm light9-fader {
+        width: 300px;
+      }
+    `,
+  ];
+  render() {
+    return html`
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <h1>Fade</h1>
+<div id="gm">
+  <light9-fader .value=${this.grandMaster} @change=${this.gmChanged}></light9-fader>grand master
+</div>
+      ${(this.fadePages?.pages || []).map(this.renderPage.bind(this))}
+
+      <div><button @click=${this.addPage}>Add new page</button></div>
+    `;
+  }
+  private renderPage(page: FadePage): TemplateResult {
+    const mappedToHw = this.currentHwPage !== undefined && page.uri.equals(this.currentHwPage);
+    return html`<div class="${mappedToHw ? "mappedToHw" : ""}">
+      <fieldset>
+        <legend>
+          Page
+          <resource-display rename .uri=${page.uri}></resource-display>
+          ${mappedToHw ? html`mapped to hardware sliders` : html`
+          <button @click=${(ev: Event) => this.mapThisToHw(page.uri)}>Map this to hw</button>
+          `}
+        </legend>
+        ${page.faderConfigs.map((fd) => html` <light9-effect-fader .uri=${fd.uri}></light9-effect-fader> `)}
+      </fieldset>
+    </div>`;
+  }
+
+  graph!: SyncedGraph;
+  ctx: NamedNode = new NamedNode(showRoot + "/fade");
+
+  @property() fadePages?: FadePages;
+  @property() currentHwPage?: NamedNode;
+  @property() grandMaster?: number;
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.compile.bind(this), `faders layout`);
+      this.graph.runHandler(this.compileGm.bind(this), `faders gm`);
+    });
+  }
+  connectedCallback(): void {
+    super.connectedCallback();
+  }
+
+  compile() {
+    const U = this.graph.U();
+    this.fadePages = undefined;
+    const fadePages = new FadePages();
+    for (let page of this.graph.subjects(U("rdf:type"), U(":FadePage"))) {
+      const fp = new FadePage(page as NamedNode);
+      try {
+        for (let fader of this.graph.objects(page, U(":fader"))) {
+          const colLit = this.graph.stringValue(fader, U(':column'))
+          fp.faderConfigs.push(new FaderConfig(fader as NamedNode, parseFloat(colLit)));
+        }
+        fp.faderConfigs.sort((a, b) => {
+          return a.column - (b.column);
+        });
+        fadePages.pages.push(fp);
+      } catch (e) { }
+    }
+    fadePages.pages.sort((a, b) => {
+      return a.uri.value.localeCompare(b.uri.value);
+    });
+    this.fadePages = fadePages;
+    this.currentHwPage = undefined;
+    try {
+      const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
+      this.currentHwPage = this.graph.uriValue(mc, U(":outputs"));
+    } catch (e) { }
+  }
+  compileGm() {
+    const U = this.graph.U();
+    this.grandMaster = undefined
+    let newVal
+    try {
+
+      newVal = this.graph.floatValue(U(':grandMaster'), U(':value'))
+    } catch (e) {
+      return
+    }
+    this.grandMaster = newVal;
+
+  }
+  gmChanged(ev: CustomEvent) {
+    const U = this.graph.U();
+    const newVal = ev.detail.value
+    // this.grandMaster = newVal;
+    this.graph.patchObject(U(':grandMaster'), U(':value'), this.graph.LiteralRoundedFloat(newVal), this.ctx)
+
+  }
+
+
+  mapThisToHw(page: NamedNode) {
+    const U = this.graph.U();
+    log("map to hw", page);
+    const mc = this.graph.uriValue(U(":midiControl"), U(":map"));
+    this.graph.patchObject(mc, U(":outputs"), page, this.ctx);
+  }
+
+  addPage() {
+    const U = this.graph.U();
+    const uri = this.graph.nextNumberedResource(showRoot + "/fadePage");
+    const adds = [
+      //
+      new Quad(uri, U("rdf:type"), U(":FadePage"), this.ctx),
+      new Quad(uri, U("rdfs:label"), N3.DataFactory.literal("unnamed"), this.ctx),
+    ];
+    for (let n = 1; n <= 8; n++) {
+      const f = this.graph.nextNumberedResource(showRoot + "/fader");
+      const s = this.graph.nextNumberedResource(showRoot + "/faderset");
+      adds.push(new Quad(uri, U(":fader"), f, this.ctx));
+      adds.push(new Quad(f, U("rdf:type"), U(":Fader"), this.ctx));
+      adds.push(new Quad(f, U(":column"), N3.DataFactory.literal("" + n), this.ctx));
+      adds.push(new Quad(f, U(":setting"), s, this.ctx));
+      adds.push(new Quad(s, U(":effectAttr"), U(":strength"), this.ctx));
+      adds.push(new Quad(s, U(":value"), this.graph.LiteralRoundedFloat(0), this.ctx));
+    }
+    this.graph.applyAndSendPatch(new Patch([], adds));
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/Light9Fader.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,146 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValueMap } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+
+import { clamp } from "../floating_color_picker";
+const log = debug("fade");
+
+class Drag {
+  constructor(public startDragPxY: number, public startDragValue: number) {}
+}
+
+@customElement("light9-fader")
+export class Light9Fader extends LitElement {
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        border: 2px gray inset;
+        background: #000;
+        height: 80px;
+      }
+      #handle {
+        background: gray;
+        border: 5px gray outset;
+        position: relative;
+        left: 0;
+        right: -25px;
+      }
+    `,
+  ];
+
+  @property() value: number = 0;
+
+  @query("#handle") handleEl!: HTMLElement;
+
+  troughHeight = 80 - 2 - 2 - 5 - 5;
+  handleHeight = 10;
+
+  drag?: Drag;
+  unmutedValue: number = 1;
+
+  render() {
+    return html` <div id="handle"><hr /></div> `;
+  }
+
+  protected update(changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.update(changedProperties);
+    if (changedProperties.has("value")) {
+      
+    }
+  }
+  valueChangedFromUi() {
+    this.value= clamp(this.value, 0, 1)
+    this.dispatchEvent(new CustomEvent("change", { detail: { value: this.value } }));
+  }
+
+  protected updated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.updated(_changedProperties);
+    const y = this.sliderTopY(this.value);
+    this.handleEl.style.top = y + "px";
+  }
+
+  protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
+    super.firstUpdated(_changedProperties);
+    this.handleEl.style.height = this.handleHeight + "px";
+    this.events();
+  }
+
+  events() {
+    const hand = this.handleEl;
+    hand.addEventListener("mousedown", (ev: MouseEvent) => {
+      ev.stopPropagation();
+      if (ev.buttons == 1) {
+        this.drag = new Drag(ev.clientY, this.value);
+      } else if (ev.buttons == 2) {
+        this.onRmb();
+      }
+    });
+    this.addEventListener("mousedown", (ev: MouseEvent) => {
+      ev.stopPropagation();
+      if (ev.buttons == 1) {
+        this.value = this.sliderValue(ev.offsetY);
+        this.valueChangedFromUi()
+        this.drag = new Drag(ev.clientY, this.value);
+      } else if (ev.buttons == 2) {
+        // RMB in trough
+        this.onRmb();
+      }
+    });
+
+    this.addEventListener("contextmenu", (event) => {
+      event.preventDefault();
+    });
+
+    this.addEventListener("wheel", (ev: WheelEvent) => {
+      ev.preventDefault();
+      this.value += ev.deltaY / this.troughHeight * -.05;
+      this.valueChangedFromUi()
+    });
+
+    const maybeDrag = (ev: MouseEvent) => {
+      if (ev.buttons != 1) return;
+      if (this.drag === undefined) return;
+      ev.stopPropagation();
+      this.onMouseDrag(ev.clientY - this.drag.startDragPxY!);
+    };
+    hand.addEventListener("mousemove", maybeDrag);
+    this.addEventListener("mousemove", maybeDrag);
+    window.addEventListener("mousemove", maybeDrag);
+
+    hand.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+    this.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+    window.addEventListener("mouseup", this.onMouseUpAnywhere.bind(this));
+  }
+  onRmb() {
+    if (this.value > 0.1) {
+      // mute
+      this.unmutedValue = this.value;
+      this.value = 0;
+    } else {
+      // unmute
+      this.value = this.unmutedValue;
+    }
+    this.valueChangedFromUi()
+  }
+  onMouseDrag(dy: number) {
+    if (this.drag === undefined) throw "unexpected";
+    this.value = this.drag.startDragValue - dy / this.troughHeight;
+    this.valueChangedFromUi()
+  }
+
+  onMouseUpAnywhere() {
+    this.drag = undefined;
+  }
+
+  sliderTopY(value: number): number {
+    const usableY = this.troughHeight - this.handleHeight;
+    const yAdj = this.handleHeight / 2 - 5 - 2;
+    return (1 - value) * usableY + yAdj;
+  }
+  sliderValue(offsetY: number): number {
+    const usableY = this.troughHeight - this.handleHeight;
+    const yAdj = this.handleHeight / 2 - 5 - 2;
+    return clamp(1 - (offsetY - yAdj) / usableY, 0, 1);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/fade/index.html	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+  <head>
+    <title>fade</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css">    
+    <script src="node_modules/fpsmeter/dist/fpsmeter.min.js"></script>
+    <script type="module" src="./Light9FadeUi"></script>
+  </head>
+  <body>
+    <light9-fade-ui></light9-fade-ui>
+  </body>
+</html>
--- a/light9/web/index.html	Thu Jun 08 12:28:27 2023 -0700
+++ b/light9/web/index.html	Thu Jun 08 13:20:23 2023 -0700
@@ -4,7 +4,7 @@
     <title>light9 home</title>
     <meta charset="utf-8" />
     <link rel="stylesheet" href="style.css" />
-    <script type="module" src="./ServiceButtonRow.ts"></script>
+    <script type="module" src="metrics/ServiceButtonRow.ts"></script>
   </head>
   <body>
     <h1>light9 home page</h1>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Effect.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,277 @@
+import debug from "debug";
+import { Literal, NamedNode, Quad, Quad_Object, Quad_Predicate, Quad_Subject, Term } from "n3";
+import { some } from "underscore";
+import { Patch } from "../patch";
+import { SyncedGraph } from "../SyncedGraph";
+import { shortShow } from "../show_specific";
+import { SubEvent } from "sub-events";
+
+// todo: Align these names with newtypes.py, which uses HexColor and VTUnion.
+type Color = string;
+export type ControlValue = number | Color | NamedNode;
+
+const log = debug("effect");
+
+function isUri(x: Term | number | string): x is NamedNode {
+  return typeof x == "object" && x.termType == "NamedNode";
+}
+
+// todo: eliminate this. address the scaling when we actually scale
+// stuff, instead of making a mess of every setting
+function valuePred(graph: SyncedGraph, attr: NamedNode): NamedNode {
+  const U = graph.U();
+  const scaledAttributeTypes = [U(":color"), U(":brightness"), U(":uv")];
+  if (some(scaledAttributeTypes, (x: NamedNode) => attr.equals(x))) {
+    return U(":value");
+  } else {
+    return U(":value");
+  }
+}
+
+// also see resourcedisplay's version of this
+function effContext(graph: SyncedGraph, uri: NamedNode): NamedNode {
+  return graph.Uri(uri.value.replace("light9.bigasterisk.com/effect", `light9.bigasterisk.com/show/${shortShow}/effect`));
+}
+
+export function newEffect(graph: SyncedGraph): NamedNode {
+  // wrong- this should be our editor's scratch effect, promoted to a
+  // real one when you name it.
+  const uri = graph.nextNumberedResource(graph.Uri("http://light9.bigasterisk.com/effect/effect"));
+
+  const effect = new Effect(graph, uri);
+  const U = graph.U();
+  const ctx = effContext(graph, uri);
+  const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => graph.Quad(s, p, o, ctx);
+
+  const addQuads = [
+    quad(uri, U("rdf:type"), U(":Effect")),
+    quad(uri, U("rdfs:label"), graph.Literal(uri.value.replace(/.*\//, ""))),
+    quad(uri, U(":publishAttr"), U(":strength")),
+    quad(uri, U(":effectFunction"), U(":effectFunction/scale")),
+  ];
+  const patch = new Patch([], addQuads);
+  log("init new effect", patch);
+  graph.applyAndSendPatch(patch);
+
+  return effect.uri;
+}
+
+// effect settings data; r/w sync with the graph
+export class Effect {
+  // :effect1 a Effect; :setting ?eset . ?eset :effectAttr :deviceSettings; :value ?dset . ?dset :device ..
+  private eset?: NamedNode;
+  private dsettings: Array<{ dset: NamedNode; device: NamedNode; deviceAttr: NamedNode; value: ControlValue }> = [];
+
+  private ctxForEffect: NamedNode;
+  settingsChanged: SubEvent<void> = new SubEvent();
+
+  constructor(public graph: SyncedGraph, public uri: NamedNode) {
+    this.ctxForEffect = effContext(this.graph, this.uri);
+    graph.runHandler(this.rebuildSettingsFromGraph.bind(this), `effect sync ${uri.value}`);
+  }
+
+  private getExistingEset(): NamedNode | null {
+    const U = this.graph.U();
+    for (let eset of this.graph.objects(this.uri, U(":setting"))) {
+      if (this.graph.uriValue(eset as Quad_Subject, U(":effectAttr")).equals(U(":deviceSettings"))) {
+        return eset as NamedNode;
+      }
+    }
+    return null;
+  }
+  private getExistingEsetValueNode(): NamedNode | null {
+    const U = this.graph.U();
+    const eset = this.getExistingEset();
+    if (eset === null) return null;
+    try {
+      return this.graph.uriValue(eset, U(":value"));
+    } catch (e) {
+      return null;
+    }
+  }
+  private patchForANewEset(): { p: Patch; eset: NamedNode } {
+    const U = this.graph.U();
+    const eset = this.graph.nextNumberedResource(U(":e_set"));
+    return {
+      eset: eset,
+      p: new Patch(
+        [],
+        [
+          //
+          new Quad(this.uri, U(":setting"), eset, this.ctxForEffect),
+          new Quad(eset, U(":effectAttr"), U(":deviceSettings"), this.ctxForEffect),
+        ]
+      ),
+    };
+  }
+
+  private rebuildSettingsFromGraph(patch?: Patch) {
+    const U = this.graph.U();
+
+    log("syncFromGraph", this.uri);
+
+    // this repeats work- it gathers all settings when really some values changed (and we might even know about them). maybe push the value-fetching into a secnod phase of the run, and have the 1st phase drop out early
+    const newSettings = [];
+
+    const deviceSettingsNode = this.getExistingEsetValueNode();
+    if (deviceSettingsNode !== null) {
+      for (let dset of Array.from(this.graph.objects(deviceSettingsNode, U(":setting"))) as NamedNode[]) {
+        //   // log(`  setting ${setting.value}`);
+        //   if (!isUri(dset)) throw new Error();
+        let value: ControlValue;
+        const device = this.graph.uriValue(dset, U(":device"));
+        const deviceAttr = this.graph.uriValue(dset, U(":deviceAttr"));
+
+        const pred = valuePred(this.graph, deviceAttr);
+        try {
+          value = this.graph.uriValue(dset, pred);
+          if (!(value as NamedNode).id.match(/^http/)) {
+            throw new Error("not uri");
+          }
+        } catch (error) {
+          try {
+            value = this.graph.floatValue(dset, pred);
+          } catch (error1) {
+            value = this.graph.stringValue(dset, pred); // this may find multi values and throw
+          }
+        }
+        //   log(`change: graph contains ${deviceAttr.value} ${value}`);
+
+        newSettings.push({ dset, device, deviceAttr, value });
+      }
+    }
+    this.dsettings = newSettings;
+    log(`settings is rebuilt to length ${this.dsettings.length}`);
+    this.settingsChanged.emit(); // maybe one emitter per dev+attr?
+    // this.onValuesChanged();
+  }
+
+  currentValue(device: NamedNode, deviceAttr: NamedNode): ControlValue | null {
+    for (let s of this.dsettings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        return s.value;
+      }
+    }
+    return null;
+  }
+
+  // change this object now, but return the patch to be applied to the graph so it can be coalesced.
+  edit(device: NamedNode, deviceAttr: NamedNode, newValue: ControlValue | null): Patch {
+    log(`edit: value=${newValue}`);
+    let existingSetting: NamedNode | null = null;
+    let result = new Patch([], []);
+
+    for (let s of this.dsettings) {
+      if (device.equals(s.device) && deviceAttr.equals(s.deviceAttr)) {
+        if (existingSetting !== null) {
+          // this is corrupt. There was only supposed to be one setting per (dev,attr) pair. But we can fix it because we're going to update existingSetting to the user's requested value.
+          log(`${this.uri.value} had two settings for ${device.value} - ${deviceAttr.value} - deleting ${s.dset}`);
+          result = result.update(this.removeEffectSetting(s.dset));
+        }
+        existingSetting = s.dset;
+      }
+    }
+
+    if (newValue !== null && this.shouldBeStored(deviceAttr, newValue)) {
+      if (existingSetting === null) {
+        result = result.update(this.addEffectSetting(device, deviceAttr, newValue));
+      } else {
+        result = result.update(this.patchExistingDevSetting(existingSetting, deviceAttr, newValue));
+      }
+    } else {
+      if (existingSetting !== null) {
+        result = result.update(this.removeEffectSetting(existingSetting));
+      }
+    }
+    return result;
+  }
+
+  shouldBeStored(deviceAttr: NamedNode, value: ControlValue | null): boolean {
+    // this is a bug for zoom=0, since collector will default it to
+    // stick at the last setting if we don't explicitly send the
+    // 0. rx/ry similar though not the exact same deal because of
+    // their remap.
+    return value != null && value !== 0 && value !== "#000000";
+  }
+
+  private addEffectSetting(device: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+    log("  _addEffectSetting", deviceAttr.value, value);
+    const U = (x: string) => this.graph.Uri(x);
+    const quad = (s: Quad_Subject, p: Quad_Predicate, o: Quad_Object) => this.graph.Quad(s, p, o, this.ctxForEffect);
+
+    let patch = new Patch([], []);
+
+    let eset = this.getExistingEset();
+    if (eset === null) {
+      const ret = this.patchForANewEset();
+      patch = patch.update(ret.p);
+      eset = ret.eset;
+    }
+
+    let dsValue;
+    try {
+      dsValue = this.graph.uriValue(eset, U(":value"));
+    } catch (e) {
+      dsValue = this.graph.nextNumberedResource(U(":ds_val"));
+      patch = patch.update(new Patch([], [quad(eset, U(":value"), dsValue)]));
+    }
+
+    const dset = this.graph.nextNumberedResource(this.uri.value + "_set");
+
+    patch = patch.update(
+      new Patch(
+        [],
+        [
+          quad(dsValue, U(":setting"), dset),
+          quad(dset, U(":device"), device),
+          quad(dset, U(":deviceAttr"), deviceAttr),
+          quad(dset, valuePred(this.graph, deviceAttr), this.nodeForValue(value)),
+        ]
+      )
+    );
+    log("  save", patch);
+    this.dsettings.push({ dset, device, deviceAttr, value });
+    return patch;
+  }
+
+  private patchExistingDevSetting(devSetting: NamedNode, deviceAttr: NamedNode, value: ControlValue): Patch {
+    log("  patch existing", devSetting.value);
+    return this.graph.getObjectPatch(
+      devSetting, //
+      valuePred(this.graph, deviceAttr),
+      this.nodeForValue(value),
+      this.ctxForEffect
+    );
+  }
+
+  private removeEffectSetting(effectSetting: NamedNode): Patch {
+    const U = (x: string) => this.graph.Uri(x);
+    log("  _removeEffectSetting", effectSetting.value);
+
+    const eset = this.getExistingEset();
+    if (eset === null) throw "unexpected";
+    const dsValue = this.graph.uriValue(eset, U(":value"));
+    if (dsValue === null) throw "unexpected";
+    const toDel = [this.graph.Quad(dsValue, U(":setting"), effectSetting, this.ctxForEffect)];
+    for (let q of this.graph.subjectStatements(effectSetting)) {
+      toDel.push(q);
+    }
+    return new Patch(toDel, []);
+  }
+
+  clearAllSettings() {
+    for (let s of this.dsettings) {
+      this.graph.applyAndSendPatch(this.removeEffectSetting(s.dset));
+    }
+  }
+
+  private nodeForValue(value: ControlValue): NamedNode | Literal {
+    if (value === null) {
+      throw new Error("no value");
+    }
+    if (isUri(value)) {
+      return value;
+    }
+    return this.graph.prettyLiteral(value);
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9AttrControl.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,195 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property, state } from "lit/decorators.js";
+import { Literal, NamedNode } from "n3";
+import { SubEvent } from "sub-events";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { ControlValue, Effect } from "./Effect";
+import { DeviceAttrRow } from "./Light9DeviceControl";
+export { Slider } from "@material/mwc-slider";
+export { Light9ColorPicker } from "../light9-color-picker";
+export { Light9Listbox } from "./Light9Listbox";
+const log = debug("settings.dev.attr");
+
+type DataTypeNames = "scalar" | "color" | "choice";
+const makeType = (d: DataTypeNames) => new NamedNode(`http://light9.bigasterisk.com/${d}`);
+
+// UI for one device attr (of any type).
+@customElement("light9-attr-control")
+export class Light9AttrControl extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      #colorControls {
+        display: flex;
+        align-items: center;
+      }
+      #colorControls > * {
+        margin: 0 3px;
+      }
+      :host {
+      }
+      mwc-slider {
+        width: 250px;
+      }
+    `,
+  ];
+
+  @property() deviceAttrRow: DeviceAttrRow | null = null;
+  @state() dataType: DataTypeNames = "scalar";
+  @property() effect: Effect | null = null;
+  @property() enableChange: boolean = false;
+  @property() value: ControlValue | null = null; // e.g. color string
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      if (this.deviceAttrRow === null) throw new Error();
+    });
+  }
+
+  connectedCallback(): void {
+    super.connectedCallback();
+    setTimeout(() => {
+      // only needed once per page layout
+      this.shadowRoot?.querySelector("mwc-slider")?.layout(/*skipUpdateUI=*/ false);
+    }, 1);
+  }
+
+  render() {
+    if (this.deviceAttrRow === null) throw new Error();
+    if (this.dataType == "scalar") {
+      const v = this.value || 0;
+      return html`<mwc-slider .value=${v} step=${1 / 255} min="0" max="1" @input=${this.onValueInput}></mwc-slider> `;
+    } else if ((this.dataType = "color")) {
+      const v = this.value || "#000";
+      return html`
+        <div id="colorControls">
+          <button @click=${this.goBlack}>0.0</button>
+          <light9-color-picker .color=${v} @input=${this.onValueInput}></light9-color-picker>
+        </div>
+      `;
+    } else if (this.dataType == "choice") {
+      return html`<light9-listbox .choices=${this.deviceAttrRow.choices} .value=${this.value}> </light9-listbox> `;
+    }
+  }
+
+  updated(changedProperties: PropertyValues<this>) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has("deviceAttrRow")) {
+      this.onDeviceAttrRowProperty();
+    }
+    if (changedProperties.has("effect")) {
+      this.onEffectProperty();
+    }
+    if (changedProperties.has("value")) {
+      this.onValueProperty();
+    }
+  }
+
+  private onValueProperty() {
+    if (this.deviceAttrRow === null) throw new Error();
+    if (!this.graph) {
+      log('ignoring value change- no graph yet')
+      return;
+    }
+    if (this.effect === null) {
+      this.value = null;
+    } else {
+      const p = this.effect.edit(
+        //
+        this.deviceAttrRow.device,
+        this.deviceAttrRow.uri,
+        this.value
+      );
+      if (!p.isEmpty()) {
+        log("Effect told us to graph.patch this:\n", p.dump());
+        this.graph.applyAndSendPatch(p);
+      }
+    }
+  }
+
+  private onEffectProperty() {
+    if (this.effect === null) {
+      log('no effect obj yet')
+      return;
+    }
+    // effect will read graph changes on its own, but emit an event when it does
+    this.effect.settingsChanged.subscribe(() => {
+      this.effectSettingsChanged();
+    });
+    this.effectSettingsChanged();
+  }
+
+  private effectSettingsChanged() {
+    // something in the settings graph is new
+    if (this.deviceAttrRow === null) throw new Error();
+    if (this.effect === null) throw new Error();
+    // log("graph->ui on ", this.deviceAttrRow.device, this.deviceAttrRow.uri);
+    const v = this.effect.currentValue(this.deviceAttrRow.device, this.deviceAttrRow.uri);
+    this.onGraphValueChanged(v);
+  }
+
+  private onDeviceAttrRowProperty() {
+    if (this.deviceAttrRow === null) throw new Error();
+    const d = this.deviceAttrRow.dataType;
+    if (d.equals(makeType("scalar"))) {
+      this.dataType = "scalar";
+    } else if (d.equals(makeType("color"))) {
+      this.dataType = "color";
+    } else if (d.equals(makeType("choice"))) {
+      this.dataType = "choice";
+    }
+  }
+
+  onValueInput(ev: CustomEvent) {
+    if (ev.detail === undefined) {
+      // not sure what this is, but it seems to be followed by good events
+      return;
+    }
+    // log(ev.type, ev.detail.value);
+    this.value = ev.detail.value;
+    // this.graphToControls.controlChanged(this.device, this.deviceAttrRow.uri, ev.detail.value);
+  }
+
+  onGraphValueChanged(v: ControlValue | null) {
+    if (this.deviceAttrRow === null) throw new Error();
+    // log("change: control must display", v, "for", this.deviceAttrRow.device.value, this.deviceAttrRow.uri.value);
+    // this.enableChange = false;
+    if (this.dataType == "scalar") {
+      if (v !== null) {
+        this.value = v;
+      } else {
+        this.value = 0;
+      }
+    } else if (this.dataType == "color") {
+      this.value = v;
+    }
+  }
+
+  goBlack() {
+    this.value = "#000000";
+  }
+
+  onChoice(value: any) {
+    // if (value != null) {
+    //   value = this.graph.Uri(value);
+    // } else {
+    //   value = null;
+    // }
+  }
+
+  onChange(value: any) {
+    // if (typeof value === "number" && isNaN(value)) {
+    //   return;
+    // } // let onChoice do it
+    // //log('change: control tells graph', @deviceAttrRow.uri.value, value)
+    // if (value === undefined) {
+    //   value = null;
+    // }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9DeviceControl.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,210 @@
+import debug from "debug";
+import { css, html, LitElement } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { unique } from "underscore";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Choice } from "./Light9Listbox";
+import { Light9AttrControl } from "./Light9AttrControl";
+import { Effect } from "./Effect";
+export { ResourceDisplay } from "../ResourceDisplay";
+export { Light9AttrControl };
+const log = debug("settings.dev");
+
+export interface DeviceAttrRow {
+  uri: NamedNode; //devattr
+  device: NamedNode;
+  attrClasses: string; // the css kind
+  dataType: NamedNode;
+  choices: Choice[];
+  // choiceSize: number;
+  // max: number;
+}
+
+// Widgets for one device with multiple Light9LiveControl rows for the attr(s).
+@customElement("light9-device-control")
+export class Light9DeviceControl extends LitElement {
+  graph!: SyncedGraph;
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+      }
+      .device {
+        border: 2px solid #151e2d;
+        margin: 4px;
+        padding: 1px;
+        background: #171717; /* deviceClass gradient added later */
+        break-inside: avoid-column;
+        width: 335px;
+      }
+      .deviceAttr {
+        border-top: 1px solid #272727;
+        padding-bottom: 2px;
+        display: flex;
+      }
+      .deviceAttr > span {
+      }
+      .deviceAttr > light9-live-control {
+        flex-grow: 1;
+      }
+      h2 {
+        font-size: 110%;
+        padding: 4px;
+        margin-top: 0;
+        margin-bottom: 0;
+      }
+      .device,
+      h2 {
+        border-top-right-radius: 15px;
+      }
+
+      #mainLabel {
+        font-size: 120%;
+        color: #9ab8fd;
+        text-decoration: initial;
+      }
+      .device.selected h2 {
+        outline: 3px solid #ffff0047;
+      }
+      .deviceAttr.selected {
+        background: #cada1829;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <div class="device ${this.devClasses}">
+        <h2 style="${this._bgStyle(this.deviceClass)}" @click=${this.onClick}>
+          <resource-display id="mainLabel" .uri="${this.uri}"></resource-display>
+          a <resource-display minor .uri="${this.deviceClass}"></resource-display>
+        </h2>
+
+        ${this.deviceAttrs.map(
+          (dattr: DeviceAttrRow) => html`
+            <div @click="onAttrClick" class="deviceAttr ${dattr.attrClasses}">
+              <span>
+                attr
+                <resource-display minor .uri=${dattr.uri}></resource-display>
+              </span>
+              <light9-attr-control .deviceAttrRow=${dattr} .effect=${this.effect}>
+              </light9-attr-control>
+            </div>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  @property() uri!: NamedNode;
+  @property() effect!: Effect;
+
+  @property() devClasses: string = ""; // the css kind
+  @property() deviceAttrs: DeviceAttrRow[] = [];
+  @property() deviceClass: NamedNode | null = null;
+  @property() selectedAttrs: Set<NamedNode> = new Set();
+
+  constructor() {
+    super();
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.syncDeviceAttrsFromGraph.bind(this), `${this.uri.value} update`);
+    });
+    this.selectedAttrs = new Set();
+  }
+
+  _bgStyle(deviceClass: NamedNode | null): string {
+    if (!deviceClass) return "";
+    let hash = 0;
+    const u = deviceClass.value;
+    for (let i = u.length - 10; i < u.length; i++) {
+      hash += u.charCodeAt(i);
+    }
+    const hue = (hash * 8) % 360;
+    const accent = `hsl(${hue}, 49%, 22%)`;
+    return `background: linear-gradient(to right, rgba(31,31,31,0) 50%, ${accent} 100%);`;
+  }
+
+  setDeviceSelected(isSel: any) {
+    this.devClasses = isSel ? "selected" : "";
+  }
+
+  setAttrSelected(devAttr: NamedNode, isSel: boolean) {
+    if (isSel) {
+      this.selectedAttrs.add(devAttr);
+    } else {
+      this.selectedAttrs.delete(devAttr);
+    }
+  }
+
+  syncDeviceAttrsFromGraph(patch?: Patch) {
+    const U = this.graph.U();
+    if (patch && !patch.containsAnyPreds([U("rdf:type"), U(":deviceAttr"), U(":dataType"), U(":choice")])) {
+      return;
+    }
+    try {
+      this.deviceClass = this.graph.uriValue(this.uri, U("rdf:type"));
+    } catch (e) {
+      // what's likely is we're going through a graph reload and the graph
+      // is gone but the controls remain
+    }
+    this.deviceAttrs = [];
+    Array.from(unique(this.graph.sortedUris(this.graph.objects(this.deviceClass, U(":deviceAttr"))))).map((da: NamedNode) =>
+      this.deviceAttrs.push(this.attrRow(da))
+    );
+    this.requestUpdate();
+  }
+
+  attrRow(devAttr: NamedNode): DeviceAttrRow {
+    let x: NamedNode;
+    const U = (x: string) => this.graph.Uri(x);
+    const dataType = this.graph.uriValue(devAttr, U(":dataType"));
+    const daRow = {
+      uri: devAttr,
+      device: this.uri,
+      dataType,
+      attrClasses: this.selectedAttrs.has(devAttr) ? "selected" : "",
+      choices: [] as Choice[],
+      choiceSize: 0,
+      max: 1,
+    };
+     if (dataType.equals(U(":choice"))) {
+      const choiceUris = this.graph.sortedUris(this.graph.objects(devAttr, U(":choice")));
+      daRow.choices = (() => {
+        const result = [];
+        for (x of Array.from(choiceUris)) {
+          result.push({ uri: x.value, label: this.graph.labelOrTail(x) });
+        }
+        return result;
+      })();
+      daRow.choiceSize = Math.min(choiceUris.length + 1, 10);
+    } else {
+      daRow.max = 1;
+      if (dataType.equals(U(":angle"))) {
+        // varies
+        daRow.max = 1;
+      }
+    }
+    return daRow;
+  }
+
+  clear() {
+    // why can't we just set their values ? what's diff about
+    // the clear state, and should it be represented with `null` value?
+    throw new Error();
+    // Array.from(this.shadowRoot!.querySelectorAll("light9-live-control")).map((lc: Element) => (lc as Light9LiveControl).clear());
+  }
+
+  onClick(ev: any) {
+    log("click", this.uri);
+    // select, etc
+  }
+
+  onAttrClick(ev: { model: { dattr: { uri: any } } }) {
+    log("attr click", this.uri, ev.model.dattr.uri);
+    // select
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9DeviceSettings.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,153 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import { NamedNode } from "n3";
+import { sortBy, uniq } from "underscore";
+import { Patch } from "../patch";
+import { getTopGraph } from "../RdfdbSyncedGraph";
+import { SyncedGraph } from "../SyncedGraph";
+import { Effect, newEffect } from "./Effect";
+export { EditChoice } from "../EditChoice";
+export { Light9DeviceControl as Light9LiveDeviceControl } from "./Light9DeviceControl";
+const log = debug("settings");
+
+@customElement("light9-device-settings")
+export class Light9DeviceSettings extends LitElement {
+  graph!: SyncedGraph;
+
+  static styles = [
+    css`
+      :host {
+        display: flex;
+        flex-direction: column;
+      }
+      #preview {
+        width: 100%;
+      }
+      #deviceControls {
+        flex-grow: 1;
+        position: relative;
+        width: 100%;
+        overflow-y: auto;
+      }
+
+      light9-device-control > div {
+        break-inside: avoid-column;
+      }
+      light9-device-control {
+        vertical-align: top;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <rdfdb-synced-graph></rdfdb-synced-graph>
+
+      <h1>effect DeviceSettings</h1>
+
+      <div id="save">
+        <div>
+          <button @click=${this.newEffect}>New effect</button>
+          <edit-choice .uri=${this.currentEffect ? this.currentEffect.uri : null} @edited=${this.onEffectChoice2} rename></edit-choice>
+          <button @click=${this.clearAll}>clear settings in this effect</button>
+        </div>
+      </div>
+
+      <div id="deviceControls">
+        ${this.devices.map(
+          (device: NamedNode) => html`
+            <light9-device-control .uri=${device} .effect=${this.currentEffect}> .graphToControls={this.graphToControls} </light9-device-control>
+          `
+        )}
+      </div>
+    `;
+  }
+
+  devices: Array<NamedNode> = [];
+  @property() currentEffect: Effect | null = null;
+  okToWriteUrl: boolean = false;
+
+  constructor() {
+    super();
+
+    getTopGraph().then((g) => {
+      this.graph = g;
+      this.graph.runHandler(this.compile.bind(this), "findDevices");
+      this.setEffectFromUrl();
+    });
+  }
+
+  onEffectChoice2(ev: CustomEvent) {
+    const uri = ev.detail.newValue as NamedNode;
+    this.setCurrentEffect(uri);
+  }
+  setCurrentEffect(uri: NamedNode) {
+    if (uri === null) {
+      this.currentEffect = null;
+      // todo: wipe the UI settings
+    } else {
+      this.currentEffect = new Effect(this.graph, uri);
+    }
+  }
+
+  updated(changedProperties: PropertyValues<this>) {
+    log("ctls udpated", changedProperties);
+    if (changedProperties.has("currentEffect")) {
+      log(`effectChoice to ${this.currentEffect?.uri?.value}`);
+      this.writeToUrl(this.currentEffect?.uri);
+    }
+    // this.graphToControls?.debugDump();
+  }
+
+  // Note that this doesn't fetch setting values, so it only should get rerun
+  // upon (rarer) changes to the devices etc. todo: make that be true
+  private compile(patch?: Patch) {
+    const U = this.graph.U();
+    // if (patch && !patchContainsPreds(patch, [U("rdf:type")])) {
+    //   return;
+    // }
+
+    this.devices = [];
+    let classes = this.graph.subjects(U("rdf:type"), U(":DeviceClass"));
+    log(`found ${classes.length} device classes`);
+    uniq(sortBy(classes, "value"), true).forEach((dc) => {
+      sortBy(this.graph.subjects(U("rdf:type"), dc), "value").forEach((dev) => {
+        this.devices.push(dev as NamedNode);
+      });
+    });
+    this.requestUpdate();
+  }
+
+  setEffectFromUrl() {
+    // not a continuous bidi link between url and effect; it only reads
+    // the url when the page loads.
+    const effect = new URL(window.location.href).searchParams.get("effect");
+    if (effect != null) {
+      this.currentEffect = new Effect(this.graph, this.graph.Uri(effect));
+    }
+    this.okToWriteUrl = true;
+  }
+
+  writeToUrl(effect: NamedNode | undefined) {
+    const effectStr = effect ? this.graph.shorten(effect) : "";
+    if (!this.okToWriteUrl) {
+      return;
+    }
+    const u = new URL(window.location.href);
+    if ((u.searchParams.get("effect") || "") === effectStr) {
+      return;
+    }
+    u.searchParams.set("effect", effectStr); // this escapes : and / and i wish it didn't
+    window.history.replaceState({}, "", u.href);
+    log("wrote new url", u.href);
+  }
+
+  newEffect() {
+    this.setCurrentEffect(newEffect(this.graph));
+  }
+
+  clearAll() {
+    this.currentEffect?.clearAllSettings()
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/Light9Listbox.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,76 @@
+import debug from "debug";
+import { css, html, LitElement, PropertyValues } from "lit";
+import { customElement, property } from "lit/decorators.js";
+const log = debug("listbox");
+export type Choice = { uri: string; label: string };
+
+@customElement("light9-listbox")
+export class Light9Listbox extends LitElement {
+  static styles = [
+    css`
+      paper-listbox {
+        --paper-listbox-background-color: none;
+        --paper-listbox-color: white;
+        --paper-listbox: {
+          /* measure biggest item? use flex for columns? */
+          column-width: 9em;
+        }
+      }
+      paper-item {
+        --paper-item-min-height: 0;
+        --paper-item: {
+          display: block;
+          border: 1px outset #0f440f;
+          margin: 0 1px 5px 0;
+          background: #0b1d0b;
+        }
+      }
+      paper-item.iron-selected {
+        background: #7b7b4a;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <paper-listbox id="list" selected="{{value}}" attr-for-selected="uri" on-focus-changed="selectOnFocus">
+        <paper-item on-focus="selectOnFocus">None</paper-item>
+        <template is="dom-repeat" items="{{choices}}">
+          <paper-item on-focus="selectOnFocus" uri="{{item.uri}}">{{item.label}}</paper-item>
+        </template>
+      </paper-listbox>
+    `;
+  }
+  @property() choices: Array<Choice> = [];
+  @property() value: String | null = null;
+
+  constructor() {
+    super();
+  }
+  selectOnFocus(ev) {
+    if (ev.target.uri === undefined) {
+      // *don't* clear for this, or we can't cycle through all choices (including none) with up/down keys
+      //this.clear();
+      //return;
+    }
+    this.value = ev.target.uri;
+  }
+  updated(changedProperties: PropertyValues) {
+    if (changedProperties.has("value")) {
+      if (this.value === null) {
+        this.clear();
+      }
+    }
+  }
+  onValue(value: String | null) {
+    if (value === null) {
+      this.clear();
+    }
+  }
+  clear() {
+    this.querySelectorAll("paper-item").forEach(function (item) {
+      item.blur();
+    });
+    this.value = null;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/README.md	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,24 @@
+This is an editor of :Effect resources, which have graphs like this:
+
+    <http://light9.bigasterisk.com/effect/effect43> a :Effect;
+    rdfs:label "effect43";
+    :publishAttr :strength;
+    :setting <http://light9.bigasterisk.com/effect/effect43_set0> .
+
+    <http://light9.bigasterisk.com/effect/effect43_set0> :device dev:strip1; :deviceAttr :color; :scaledValue 0.337 .
+
+# Objects
+
+SyncedGraph has the true data.
+
+Effect sends/receives data from one :Effect resource in the graph. Only Effect knows that there are :setting edges in the graph. Everything else on the page
+sees the effect as a list of (effect, device, deviceAttr, value) tuples. Those values are non-null. Control elements that aren't contributing the effect
+(_probably_ at their zero position, but this is not always true) have a null value.
+
+GraphToControls has a record of all the control widgets on the page, and sends/receives edits with them.
+
+We deal in ControlValue objects, which are the union of a brightness, color, choice, etc. Some layers deal in ControlValue|null. A null value means there is no
+:setting for that device+attribute
+
+SyncedGraph and GraphToControls live as long as the web page. Effect can come and go (though there is a plan to make a separate web page url per effect, then
+the Effect would live as long as the page too)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/live/index.html	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html>
+  <head>
+    <title>device settings</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../style.css" />
+    <script type="module" src="./Light9DeviceSettings"></script>
+  </head>
+  <body>
+    <style>
+      body,
+      html {
+        margin: 0;
+      }
+      light9-device-settings {
+        position: absolute;
+        left: 2px;
+        top: 2px;
+        right: 8px;
+        bottom: 0;
+      }
+    </style>
+    <light9-device-settings></light9-device-settings>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/metrics/ServiceButtonRow.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,66 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export { StatsLine } from "./StatsLine";
+
+@customElement("service-button-row")
+export class ServiceButtonRow extends LitElement {
+  @property() name: string = "?";
+  @property({ type:Boolean, attribute: "metrics" }) hasMetrics: boolean = false;
+  static styles = [
+    css`
+      :host {
+        padding-bottom: 10px;
+        border-bottom: 1px solid #333;
+      }
+      a {
+        color: #7d7dec;
+      }
+      div {
+        display: flex;
+        justify-content: space-between;
+        padding: 2px 3px;
+      }
+      .left {
+        display: inline-block;
+        margin-right: 3px;
+        flex-grow: 1;
+        min-width: 9em;
+      }
+      .window {
+      }
+      .serviceGrid > td {
+        border: 5px solid red;
+        display: inline-block;
+      }
+      .big {
+        font-size: 120%;
+        display: inline-block;
+        padding: 10px 0;
+      }
+
+      :host > div {
+        display: inline-block;
+        vertical-align: top;
+      }
+      :host > div:nth-child(2) {
+        width: 9em;
+      }
+    `,
+  ];
+
+  render() {
+    return html`
+      <div>
+        <div class="left"><a class="big" href="${this.name}/">${this.name}</a></div>
+        <div class="window"><button @click="${this.click}">window</button></div>
+        ${this.hasMetrics ? html`<div><a href="${this.name}/metrics">metrics</a></div>` : ""}
+      </div>
+
+      ${this.hasMetrics ? html`<div id="stats"><stats-line name="${this.name}"></div>` : ""}
+      `;
+  }
+
+  click() {
+    window.open(this.name + "/", "_blank", "scrollbars=1,resizable=1,titlebar=0,location=0");
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/metrics/StatsLine.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,306 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators.js";
+export { StatsProcess } from "./StatsProcess";
+import parsePrometheusTextFormat from "parse-prometheus-text-format";
+import debug from "debug";
+import { clamp } from "../floating_color_picker";
+const log = debug("home");
+
+interface Value {
+  labels: { string: string };
+  value?: string;
+  count?: number;
+  sum?: number;
+  buckets?: { [value: string]: string };
+}
+interface Metric {
+  name: string;
+  help: string;
+  type: "GAUGE" | "SUMMARY" | "COUNTER" | "HISTOGRAM" | "UNTYPED";
+  metrics: Value[];
+}
+type Metrics = Metric[];
+
+function nonBoring(m: Metric) {
+  return (
+    !m.name.endsWith("_created") && //
+    !m.name.startsWith("python_gc_") &&
+    m.name != "python_info" &&
+    m.name != "process_max_fds" &&
+    m.name != "process_virtual_memory_bytes" &&
+    m.name != "process_resident_memory_bytes" &&
+    m.name != "process_start_time_seconds" &&
+    m.name != "process_cpu_seconds_total"
+  );
+}
+
+@customElement("stats-line")
+export class StatsLine extends LitElement {
+  @property() name = "?";
+  @property() stats: Metrics = [];
+
+  prevCpuNow = 0;
+  prevCpuTotal = 0;
+  @property() cpu = 0;
+  @property() mem = 0;
+
+  updated(changedProperties: any) {
+    changedProperties.forEach((oldValue: any, propName: string) => {
+      if (propName == "name") {
+        const reload = () => {
+          fetch(this.name + "/metrics").then((resp) => {
+            if (resp.ok) {
+              resp
+                .text()
+                .then((msg) => {
+                  this.stats = parsePrometheusTextFormat(msg) as Metrics;
+                  this.extractProcessStats(this.stats);
+                  setTimeout(reload, 1000);
+                })
+                .catch((err) => {
+                  log(`${this.name} failing`, err)
+                  setTimeout(reload, 1000);
+                });
+            } else {
+              if (resp.status == 502) {
+                setTimeout(reload, 5000);
+              }
+              // 404: likely not mapped to a responding server
+            }
+          });
+        };
+        reload();
+      }
+    });
+  }
+  extractProcessStats(stats: Metrics) {
+    stats.forEach((row: Metric) => {
+      if (row.name == "process_resident_memory_bytes") {
+        this.mem = parseFloat(row.metrics[0].value!) / 1024 / 1024;
+      }
+      if (row.name == "process_cpu_seconds_total") {
+        const now = Date.now() / 1000;
+        const cpuSecondsTotal = parseFloat(row.metrics[0].value!);
+        this.cpu = (cpuSecondsTotal - this.prevCpuTotal) / (now - this.prevCpuNow);
+        this.prevCpuTotal = cpuSecondsTotal;
+        this.prevCpuNow = now;
+      }
+    });
+  }
+
+  static styles = [
+    css`
+      :host {
+        border: 2px solid #46a79f;
+        display: inline-block;
+      }
+      table {
+        border-collapse: collapse;
+        background: #000;
+        color: #ccc;
+        font-family: sans-serif;
+      }
+      th,
+      td {
+        outline: 1px solid #000;
+      }
+      th {
+        padding: 2px 4px;
+        background: #2f2f2f;
+        text-align: left;
+      }
+      td {
+        padding: 0;
+        vertical-align: top;
+        text-align: center;
+      }
+      td.val {
+        padding: 2px 4px;
+        background: #3b5651;
+      }
+      .recents {
+        display: flex;
+        align-items: flex-end;
+        height: 30px;
+      }
+      .recents > div {
+        width: 3px;
+        background: red;
+        border-right: 1px solid black;
+      }
+      .bigInt {
+        min-width: 6em;
+      }
+    `,
+  ];
+
+  tdWrap(content: TemplateResult): TemplateResult {
+    return html`<td>${content}</td>`;
+  }
+
+  recents(d: any, path: string[]): TemplateResult {
+    const hi = Math.max.apply(null, d.recents);
+    const scl = 30 / hi;
+
+    const bar = (y: number) => {
+      let color;
+      if (y < d.average) {
+        color = "#6a6aff";
+      } else {
+        color = "#d09e4c";
+      }
+      return html`<div class="bar" style="height: ${y * scl}px; background: ${color};"></div>`;
+    };
+    return html`<td>
+      <div class="recents">${d.recents.map(bar)}</div>
+      <div>avg=${d.average.toPrecision(3)}</div>
+    </td>`;
+  }
+
+  table(d: Metrics, path: string[]): TemplateResult {
+    const byName = new Map<string, Metric>();
+    d.forEach((row) => {
+      byName.set(row.name, row);
+    });
+    let cols = d.map((row) => row.name);
+    cols.sort();
+
+    if (path.length == 0) {
+      ["webServer", "process"].forEach((earlyKey) => {
+        let i = cols.indexOf(earlyKey);
+        if (i != -1) {
+          cols = [earlyKey].concat(cols.slice(0, i), cols.slice(i + 1));
+        }
+      });
+    }
+
+    const th = (col: string): TemplateResult => {
+      return html`<th>${col}</th>`;
+    };
+    const td = (col: string): TemplateResult => {
+      const cell = byName.get(col)!;
+      return html`${this.drawLevel(cell, path.concat(col))}`;
+    };
+    return html` <table>
+      <tr>
+        ${cols.map(th)}
+      </tr>
+      <tr>
+        ${cols.map(td)}
+      </tr>
+    </table>`;
+  }
+
+  drawLevel(d: Metric, path: string[]) {
+    return html`[NEW ${JSON.stringify(d)} ${path}]`;
+  }
+
+
+  valueDisplay(m: Metric, v: Value): TemplateResult {
+    if (m.type == "GAUGE") {
+      return html`${v.value}`;
+    } else if (m.type == "COUNTER") {
+      return html`${v.value}`;
+    } else if (m.type == "HISTOGRAM") {
+      return this.histoDisplay(v.buckets!);
+    } else if (m.type == "UNTYPED") {
+      return html`${v.value}`;
+    } else if (m.type == "SUMMARY") {
+      if (!v.count) {
+        return html`err: summary without count`;
+      }
+      return html`n=${v.count} percall=${((v.count && v.sum ? v.sum / v.count : 0) * 1000).toPrecision(3)}ms`;
+    } else {
+      throw m.type;
+    }
+  }
+
+  private histoDisplay(b: { [value: string]: string }) {
+    const lines: TemplateResult[] = [];
+    let firstLevel;
+    let lastLevel;
+    let prev = 0;
+
+    let maxDelta = 0;
+    for (let level in b) {
+      if (firstLevel === undefined) firstLevel = level;
+      lastLevel = level;
+      let count = parseFloat(b[level]);
+      let delta = count - prev;
+      prev = count;
+      if (delta > maxDelta) maxDelta = delta;
+    }
+    prev = 0;
+    const maxBarH = 30;
+    for (let level in b) {
+      let count = parseFloat(b[level]);
+      let delta = count - prev;
+      prev = count;
+      let levelf = parseFloat(level);
+      const h = clamp((delta / maxDelta) * maxBarH, 1, maxBarH);
+      lines.push(
+        html`<div
+          title="bucket=${level} count=${count}"
+          style="background: yellow; margin-right: 1px; width: 8px; height: ${h}px; display: inline-block"
+        ></div>`
+      );
+    }
+    return html`${firstLevel} ${lines} ${lastLevel}`;
+  }
+
+  tightLabel(labs: { [key: string]: string }): string {
+    const d: { [key: string]: string } = {}
+    for (let k in labs) {
+      if (k == 'app_name') continue;
+      if (k == 'output') continue;
+      if (k=='status_code'&&labs[k]=="200") continue;
+      d[k] = labs[k]
+    }
+    const ret = JSON.stringify(d)
+    return ret == "{}" ? "" : ret
+  }
+  tightMetric(name: string): string {
+    return name
+    .replace('starlette', '⭐')
+    .replace("_request" ,"_req")
+    .replace("_duration" ,"_dur")
+    .replace('_seconds', '_s')
+  }
+  render() {
+    const now = Date.now() / 1000;
+
+    const displayedStats = this.stats.filter(nonBoring);
+    return html`
+      <div>
+        <table>
+          ${displayedStats.map(
+      (row, rowNum) => html`
+              <tr>
+                <th>${this.tightMetric(row.name)}</th>
+                <td>
+                  <table>
+                    ${row.metrics.map(
+        (v) => html`
+                        <tr>
+                          <td>${this.tightLabel(v.labels)}</td>
+                          <td>${this.valueDisplay(row, v)}</td>
+                        </tr>
+                      `
+      )}
+                  </table>
+                </td>
+                ${rowNum == 0
+          ? html`
+                      <td rowspan="${displayedStats.length}">
+                        <stats-process id="proc" cpu="${this.cpu}" mem="${this.mem}"></stats-process>
+                      </td>
+                    `
+          : ""}
+              </tr>
+            `
+    )}
+        </table>
+      </div>
+    `;
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/web/metrics/StatsProcess.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -0,0 +1,90 @@
+import { LitElement, html, css } from "lit";
+import { customElement, property } from "lit/decorators.js";
+import debug from "debug";
+
+const log = debug("process");
+
+const remap = (x: number, lo: number, hi: number, outLo: number, outHi: number) => {
+  return outLo + (outHi - outLo) * Math.max(0, Math.min(1, (x - lo) / (hi - lo)));
+};
+
+@customElement("stats-process")
+export class StatsProcess extends LitElement {
+  // inspired by https://codepen.io/qiruiyin/pen/qOopQx
+  @property() cpu = 0; // process_cpu_seconds_total
+  @property() mem = 0; // process_resident_memory_bytes
+
+  w = 64;
+  h = 64;
+  revs = 0;
+  prev = 0;
+  canvas?: HTMLCanvasElement;
+  ctx?: CanvasRenderingContext2D;
+  connectedCallback() {
+    super.connectedCallback();
+    this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
+    this.prev = Date.now() / 1000;
+
+    var animate = () => {
+      requestAnimationFrame(animate);
+      this.redraw();
+    };
+    animate();
+  }
+  initCanvas(canvas: HTMLCanvasElement) {
+    if (!canvas) {
+      return;
+    }
+    this.canvas = canvas;
+    this.ctx = this.canvas.getContext("2d")!;
+
+    this.canvas.width = this.w;
+    this.canvas.height = this.h;
+  }
+  redraw() {
+    if (!this.ctx) {
+      this.initCanvas(this.shadowRoot!.firstElementChild as HTMLCanvasElement);
+    }
+    if (!this.ctx) return;
+
+    this.canvas!.setAttribute("title", 
+    `cpu ${new Number(this.cpu).toPrecision(3)}% mem ${new Number(this.mem).toPrecision(3)}MB`);
+
+    const now = Date.now() / 1000;
+    const ctx = this.ctx;
+    ctx.beginPath();
+    // wrong type of fade- never goes to 0
+    ctx.fillStyle = "#00000003";
+    ctx.fillRect(0, 0, this.w, this.h);
+    const dt = now - this.prev;
+    this.prev = now;
+
+    const size = remap(this.mem.valueOf() / 1024 / 1024, /*in*/ 20, 80, /*out*/ 3, 30);
+    this.revs += dt * remap(this.cpu.valueOf(), /*in*/ 0, 100, /*out*/ 4, 120);
+    const rad = remap(size, /*in*/ 3, 30, /*out*/ 14, 5);
+
+    var x = this.w / 2 + rad * Math.cos(this.revs / 6.28),
+      y = this.h / 2 + rad * Math.sin(this.revs / 6.28);
+
+    ctx.save();
+    ctx.beginPath();
+    ctx.fillStyle = "hsl(194, 100%, 42%)";
+    ctx.arc(x, y, size, 0, 2 * Math.PI);
+    ctx.fill();
+    ctx.restore();
+  }
+
+  static styles = [
+    css`
+      :host {
+        display: inline-block;
+        width: 64px;
+        height: 64px;
+      }
+    `,
+  ];
+
+  render() {
+    return html`<canvas></canvas>`;
+  }
+}
--- a/light9/web/vite.config.ts	Thu Jun 08 12:28:27 2023 -0700
+++ b/light9/web/vite.config.ts	Thu Jun 08 13:20:23 2023 -0700
@@ -1,16 +1,15 @@
 import { defineConfig } from "vite";
 
-const servicePort = 8200; // (not really, for homepage)
 export default defineConfig({
   base: "/",
   root: "./light9/web",
-  publicDir: ".",
+  // publicDir: ".",
   server: {
     host: "0.0.0.0",
     strictPort: true,
-    port: servicePort + 100,
+    port: 8300,
     hmr: {
-      port: servicePort + 200,
+      port: 8301,
     },
   },
   clearScreen: false,