diff --git a/glm/src/glm.gleam.bak b/glm/src/glm.gleam.bak
deleted file mode 100644
index e7a0ddc..0000000
--- a/glm/src/glm.gleam.bak
+++ /dev/null
@@ -1,6 +0,0 @@
-import field_adder
-
-pub fn main() -> Nil {
- let assert Ok(_) = field_adder.register()
- Nil
-}
diff --git a/interim-server/templates/lens.html b/interim-server/templates/lens.html
index b783231..8637aa7 100644
--- a/interim-server/templates/lens.html
+++ b/interim-server/templates/lens.html
@@ -1,49 +1,49 @@
{% extends "base.html" %}
{% block main %}
-
-
-
-
-
+
+
+
-
-
-
- {% for field in fields %}
- |
- {{ field.label.clone().unwrap_or(field.name.clone()) }}
- |
+
+
+
+
+ {% for field in fields %}
+ |
+ {{ field.label.clone().unwrap_or(field.name.clone()) }}
+ |
+ {% endfor %}
+
+
+ |
+
+
+
+ {% for row in rows %}
+
+ {% for field in fields %}
+ |
+ {% match field.get_value_encodable(row.data) %}
+ {% when Ok with (encodable) %}
+ <{{ field.webc_tag() | safe }}
+ {% for (k, v) in field.webc_custom_attrs() %}
+ {{ k }}="{{ v }}"
+ {% endfor %}
+ column="{{ field.name }}"
+ value="{{ encodable | json }}"
+ class="cell"
+ >
+ {{ field.render(encodable) | safe }}
+ {{ field.webc_tag() | safe }}
+ {% when Err with (err) %}
+ {{ err }}
+ {% endmatch %}
+ |
+ {% endfor %}
+
{% endfor %}
-
-
- |
-
-
-
- {% for row in rows %}
-
- {% for field in fields %}
- |
- {% match field.get_value_encodable(row.data) %}
- {% when Ok with (encodable) %}
- <{{ field.webc_tag() | safe }}
- {% for (k, v) in field.webc_custom_attrs() %}
- {{ k }}="{{ v }}"
- {% endfor %}
- column="{{ field.name }}"
- value="{{ encodable | json }}"
- class="cell"
- >
- {{ field.render(encodable) | safe }}
- {{ field.webc_tag() | safe }}
- {% when Err with (err) %}
- {{ err }}
- {% endmatch %}
- |
- {% endfor %}
-
- {% endfor %}
-
-
+
+
+
{% endblock %}
diff --git a/mise.toml b/mise.toml
index 5beb385..b40fd53 100644
--- a/mise.toml
+++ b/mise.toml
@@ -16,17 +16,12 @@ run = "cargo run serve"
description = "Run the server. For development: `mise watch --restart serve`."
sources = ["**/*.rs", "**/*.html"]
-[tasks.vite]
-run = "deno task build"
-dir = "./components"
-sources = ["**/*.ts"]
+[tasks.build-js]
+run = "sh build.sh"
+dir = "./webc"
+sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"]
-[tasks.lustre]
-run = "gleam run -m lustre/dev build component field_adder --outdir=../glm_dist"
-dir = "./glm"
-sources = ["**/*.gleam"]
-
-[tasks.sass]
+[tasks.build-css]
run = "sass sass/:css_dist/"
sources = ["sass/**/*.scss"]
diff --git a/glm/.gitignore b/webc/.gitignore
similarity index 100%
rename from glm/.gitignore
rename to webc/.gitignore
diff --git a/glm/README.md b/webc/README.md
similarity index 100%
rename from glm/README.md
rename to webc/README.md
diff --git a/webc/build.sh b/webc/build.sh
new file mode 100644
index 0000000..8984040
--- /dev/null
+++ b/webc/build.sh
@@ -0,0 +1,5 @@
+# Compile each .gleam file in src/ as a separate component module.
+ls src | \
+ grep '_component.gleam' | \
+ xargs -I {} basename {} .gleam | \
+ xargs -I {} gleam run -m lustre/dev build component {} --outdir=../js_dist
diff --git a/glm/gleam.toml b/webc/gleam.toml
similarity index 98%
rename from glm/gleam.toml
rename to webc/gleam.toml
index c25b6f9..b9673f9 100644
--- a/glm/gleam.toml
+++ b/webc/gleam.toml
@@ -1,4 +1,4 @@
-name = "glm"
+name = "webc"
version = "1.0.0"
target = "javascript"
diff --git a/glm/manifest.toml b/webc/manifest.toml
similarity index 100%
rename from glm/manifest.toml
rename to webc/manifest.toml
diff --git a/webc/src/cell_text_component.gleam b/webc/src/cell_text_component.gleam
new file mode 100644
index 0000000..d4b9b61
--- /dev/null
+++ b/webc/src/cell_text_component.gleam
@@ -0,0 +1,88 @@
+import gleam/dynamic
+import gleam/dynamic/decode
+import lustre.{type App}
+import lustre/attribute as attr
+import lustre/component
+import lustre/effect.{type Effect}
+import lustre/element.{type Element}
+import lustre/element/html
+
+import context
+
+pub const name: String = "cell-text"
+
+pub fn component() -> App(Nil, Model, Msg) {
+ lustre.component(init, update, view, [
+ component.on_attribute_change("value", fn(value) {
+ ParentChangedValue(value) |> Ok
+ }),
+ component.on_attribute_change("column", fn(value) {
+ ParentChangedColumn(value) |> Ok
+ }),
+ component.on_attribute_change("selected", fn(value) {
+ ParentChangedSelected(value != "") |> Ok
+ }),
+ component.on_attribute_change("editing", fn(value) {
+ ParentChangedEditing(value != "") |> Ok
+ }),
+ component.on_property_change("pkeys", {
+ decode.list(of: decode.string) |> decode.map(ParentChangedPkeys)
+ }),
+ ])
+}
+
+pub type Model {
+ Model(
+ root_path: String,
+ column: String,
+ pkeys: List(String),
+ selected: Bool,
+ editing: Bool,
+ )
+}
+
+fn init(_) -> #(Model, Effect(Msg)) {
+ #(
+ Model(
+ root_path: "",
+ column: "",
+ pkeys: [""],
+ selected: False,
+ editing: False,
+ ),
+ context.request_context(
+ context: dynamic.string("root_path"),
+ subscribe: False,
+ decoder: decode.string |> decode.map(AncestorChangedRootPath),
+ ),
+ )
+}
+
+pub type Msg {
+ AncestorChangedRootPath(String)
+ ParentChangedColumn(String)
+ ParentChangedEditing(Bool)
+ ParentChangedValue(String)
+ ParentChangedSelected(Bool)
+ ParentChangedPkeys(List(String))
+}
+
+fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
+ case msg {
+ AncestorChangedRootPath(root_path) -> #(
+ Model(..model, root_path:),
+ effect.none(),
+ )
+ _ -> #(model, effect.none())
+ }
+}
+
+fn view(model: Model) -> Element(Msg) {
+ element.fragment([
+ html.link([
+ attr.rel("stylesheet"),
+ attr.href(model.root_path <> "/css_dist/cell_text/index.css"),
+ ]),
+ html.div([], [html.text(model.root_path)]),
+ ])
+}
diff --git a/webc/src/context.ffi.mjs b/webc/src/context.ffi.mjs
new file mode 100644
index 0000000..cdb8712
--- /dev/null
+++ b/webc/src/context.ffi.mjs
@@ -0,0 +1,17 @@
+class ContextRequestEvent extends Event {
+ constructor(context, subscribe, callback) {
+ super("context-request", { bubbles: true, composed: true });
+ this.context = context;
+ this.subscribe = subscribe;
+ this.callback = callback;
+ }
+}
+
+export function emitContextRequest(root, context, subscribe, callback) {
+ root.dispatchEvent(new ContextRequestEvent(context, subscribe, callback));
+}
+
+// FFI shim so that Gleam can pass around Javascript functions as dynamic values
+export function callCallback(callback, value) {
+ callback(value);
+}
diff --git a/webc/src/context.gleam b/webc/src/context.gleam
new file mode 100644
index 0000000..15468c6
--- /dev/null
+++ b/webc/src/context.gleam
@@ -0,0 +1,93 @@
+import gleam/dynamic.{type Dynamic}
+import gleam/dynamic/decode.{type Decoder}
+import gleam/option.{type Option}
+import lustre/attribute.{type Attribute}
+import lustre/effect.{type Effect}
+import lustre/event
+
+/// WARNING: Lifecycle hooks in Lustre are currently limited to non-existent,
+/// so it's not possible to unsubscribe from context updates on component
+/// teardown. Be cautious using this effect with components that are not
+/// expected to be permanent fixtures on a page. (Refer to
+/// https://github.com/lustre-labs/lustre/issues/320.)
+pub fn request_context(
+ context context: Dynamic,
+ subscribe subscribe: Bool,
+ decoder decoder: Decoder(msg),
+) -> Effect(msg) {
+ use dispatch, root <- effect.before_paint
+ use value, _unsubscribe <- emit_context_request(root, context, subscribe)
+ case decode.run(value, decoder) {
+ Ok(msg) -> {
+ dispatch(msg)
+ }
+ Error(_) -> Nil
+ }
+ Nil
+}
+
+@external(javascript, "./context.ffi.mjs", "emitContextRequest")
+fn emit_context_request(
+ root _root: Dynamic,
+ context _context: Dynamic,
+ subscribe _subscribe: Bool,
+ callback _callback: fn(Dynamic, Option(fn() -> Nil)) -> Nil,
+) -> Nil {
+ Nil
+}
+
+/// Capture "context-request" events querying a particular context type.
+/// Intended to be used in a context provider, on a child element near the
+/// component root.
+pub fn on_context_request(
+ context match_context: Dynamic,
+ handler handler: fn(Dynamic, Bool) -> msg,
+) -> Attribute(msg) {
+ event.advanced("context-request", {
+ use ev_context <- decode.field("context", decode.dynamic)
+ use subscribe <- decode.field("subscribe", decode.bool)
+ use callback <- decode.field("callback", decode.dynamic)
+ case ev_context == match_context {
+ True -> {
+ decode.success(event.handler(
+ handler(callback, subscribe),
+ False,
+ ev_context == match_context,
+ ))
+ }
+ False ->
+ // returning a DecodeError seems like the only way to not trigger a message dispatch
+ decode.failure(
+ event.handler(handler(callback, subscribe), False, False),
+ "Context",
+ )
+ }
+ })
+}
+
+/// Effect for passing context value back to consumers who have requested it.
+pub fn update_consumers(
+ callbacks callbacks: List(Dynamic),
+ value value: Dynamic,
+) -> Effect(msg) {
+ use _dispatch <- effect.from
+ do_update_consumers(callbacks:, value:)
+}
+
+fn do_update_consumers(
+ callbacks callbacks: List(Dynamic),
+ value value: Dynamic,
+) -> Nil {
+ case callbacks {
+ [] -> Nil
+ [cb, ..rest] -> {
+ call_callback(callback: cb, value:)
+ do_update_consumers(rest, value)
+ }
+ }
+}
+
+@external(javascript, "./context.ffi.mjs", "callCallback")
+fn call_callback(callback _callback: Dynamic, value _value: Dynamic) -> Nil {
+ Nil
+}
diff --git a/glm/src/field_adder.ffi.mjs b/webc/src/field_adder.ffi.mjs
similarity index 100%
rename from glm/src/field_adder.ffi.mjs
rename to webc/src/field_adder.ffi.mjs
diff --git a/glm/src/field_adder.gleam b/webc/src/field_adder_component.gleam
similarity index 100%
rename from glm/src/field_adder.gleam
rename to webc/src/field_adder_component.gleam
diff --git a/webc/src/viewer_controller_component.gleam b/webc/src/viewer_controller_component.gleam
new file mode 100644
index 0000000..6ef6df8
--- /dev/null
+++ b/webc/src/viewer_controller_component.gleam
@@ -0,0 +1,66 @@
+import gleam/dynamic.{type Dynamic}
+import lustre.{type App}
+import lustre/component
+import lustre/effect.{type Effect}
+import lustre/element.{type Element}
+import lustre/element/html
+
+import context
+
+pub const name: String = "viewer-controller"
+
+pub fn component() -> App(Nil, Model, Msg) {
+ lustre.component(init, update, view, [
+ component.on_attribute_change("root-path", fn(value) {
+ ParentChangedRootPath(value) |> Ok
+ }),
+ ])
+}
+
+pub type Model {
+ Model(root_path: String, root_path_consumers: List(Dynamic))
+}
+
+fn init(_) -> #(Model, Effect(Msg)) {
+ #(Model(root_path: "", root_path_consumers: []), effect.none())
+}
+
+pub type Msg {
+ ParentChangedRootPath(String)
+ ChildRequestedRootPath(Dynamic, Bool)
+}
+
+fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
+ case msg {
+ ParentChangedRootPath(root_path) -> #(
+ Model(..model, root_path:),
+ context.update_consumers(
+ model.root_path_consumers,
+ dynamic.string(root_path),
+ ),
+ )
+ ChildRequestedRootPath(callback, subscribe) -> #(
+ case subscribe {
+ True ->
+ Model(..model, root_path_consumers: [
+ callback,
+ ..model.root_path_consumers
+ ])
+ False -> model
+ },
+ context.update_consumers([callback], dynamic.string(model.root_path)),
+ )
+ }
+}
+
+fn view(_: Model) -> Element(Msg) {
+ html.div(
+ [
+ context.on_context_request(
+ dynamic.string("root_path"),
+ ChildRequestedRootPath,
+ ),
+ ],
+ [component.default_slot([], [])],
+ )
+}