diff --git a/.github/workflows/eval_in_wasm_build_test.yml b/.github/workflows/eval_in_wasm_build_test.yml index 01d9c974..58b7e4ae 100644 --- a/.github/workflows/eval_in_wasm_build_test.yml +++ b/.github/workflows/eval_in_wasm_build_test.yml @@ -56,6 +56,10 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install working-directory: . diff --git a/.github/workflows/hello_popcorn_build_test.yml b/.github/workflows/hello_popcorn_build_test.yml index a98e6274..aae12a8b 100644 --- a/.github/workflows/hello_popcorn_build_test.yml +++ b/.github/workflows/hello_popcorn_build_test.yml @@ -54,6 +54,10 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install working-directory: . diff --git a/.github/workflows/langtour_e2e_test.yml b/.github/workflows/langtour_e2e_test.yml index 8f6c5a4f..f115de70 100644 --- a/.github/workflows/langtour_e2e_test.yml +++ b/.github/workflows/langtour_e2e_test.yml @@ -64,6 +64,10 @@ jobs: working-directory: language-tour/elixir_tour - name: Install pnpm dependencies + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install --frozen-lockfile - name: Build popcorn JS package diff --git a/.github/workflows/local_live_view_build.yml b/.github/workflows/local_live_view_build.yml index 5f64be4b..08f81e4d 100644 --- a/.github/workflows/local_live_view_build.yml +++ b/.github/workflows/local_live_view_build.yml @@ -57,6 +57,10 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install working-directory: . diff --git a/.github/workflows/otp_js_e2e_test.yml b/.github/workflows/otp_js_e2e_test.yml index c4784ac7..6f52430f 100644 --- a/.github/workflows/otp_js_e2e_test.yml +++ b/.github/workflows/otp_js_e2e_test.yml @@ -67,6 +67,10 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install - name: Setup Playwright diff --git a/.github/workflows/popcorn_build_test.yml b/.github/workflows/popcorn_build_test.yml index cd2bd6f5..4b362b46 100644 --- a/.github/workflows/popcorn_build_test.yml +++ b/.github/workflows/popcorn_build_test.yml @@ -69,6 +69,10 @@ jobs: - name: Install JS dependencies working-directory: . + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install - name: Setup Playwright diff --git a/.github/workflows/popcorn_js_e2e_test.yml b/.github/workflows/popcorn_js_e2e_test.yml index 6b8049e2..bb11529f 100644 --- a/.github/workflows/popcorn_js_e2e_test.yml +++ b/.github/workflows/popcorn_js_e2e_test.yml @@ -47,6 +47,10 @@ jobs: cache-dependency-path: pnpm-lock.yaml - name: Install JS dependencies + env: + # Required by tree-sitter native bindings which use C++20 features + # and fail to compile without this flag on the default GCC version in CI. + CXXFLAGS: "-std=c++20" run: pnpm install - name: Build popcorn JS package diff --git a/examples/local-lv-forms/assets/css/app.css b/examples/local-lv-forms/assets/css/app.css index ba5f5973..9402e3e5 100644 --- a/examples/local-lv-forms/assets/css/app.css +++ b/examples/local-lv-forms/assets/css/app.css @@ -124,7 +124,7 @@ font-size: 15px; } -.title { +h1 { padding: 30px 25px; font-size: 30px; font-weight: bold; diff --git a/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex b/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex index 46600b51..72391af1 100644 --- a/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex +++ b/examples/local-lv-forms/lib/form_demo_web/controllers/page_html/home.html.heex @@ -1,5 +1,5 @@
-

LocalLiveView & Phoenix Integration: Forms Demo

+

LocalLiveView & Phoenix Integration: Forms Demo

Explore seamless form handling and LocalLiveView integration.

diff --git a/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex b/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex index 03c6c471..d0ba6697 100644 --- a/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex +++ b/examples/local-lv-forms/lib/form_demo_web/live/form_demo_live.ex @@ -6,7 +6,7 @@ defmodule FormDemoWeb.FormDemoLive do

<.local_live_view id={"form-demo-local-#{@socket.id}"} view="FormDemoLocal" />
-

[Server Runtime] User List:

+

[Server Runtime] User List:

    <%= for user <- @users do %>
  • Username: {user["username"]}, Email: {user["email"]}
  • diff --git a/examples/local-lv-forms/local/lib/form_demo_local.ex b/examples/local-lv-forms/local/lib/form_demo_local.ex index 805b8c4d..3cdaf692 100644 --- a/examples/local-lv-forms/local/lib/form_demo_local.ex +++ b/examples/local-lv-forms/local/lib/form_demo_local.ex @@ -6,22 +6,26 @@ defmodule FormDemoLocal do @impl true def render(assigns) do ~H""" +

    Add new user

    <.form for={@form} id={@form.id} phx-change="validate" phx-submit="save"> - <.input type="text" field={@form[:username]} /> + <.input type="text" field={@form[:username]} placeholder="at least 4 characters" /> - <.input type="text" field={@form[:email]} /> -
    - + <.input type="text" field={@form[:email]} placeholder="name@domain.com" /> +
    + +
    -
    - -
    -
    -

    [Local Runtime] User List:

    +
    +

    [Local Runtime] User List:

    + <%= if Enum.empty?(@users) do %> +

    No users yet - save one above

    + <% end %>
      <%= for user <- @users do %>
    • Username: {user.username}, Email: {user.email}
    • diff --git a/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex b/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex new file mode 100644 index 00000000..66d9cf71 --- /dev/null +++ b/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex @@ -0,0 +1,77 @@ +defmodule FormDemoLocalPresentation do + use LocalLiveView + + defdelegate render(assigns), to: FormDemoLocal + + def mount(params, session, socket) do + result = FormDemoLocal.mount(params, session, socket) + {:ok, new_socket} = result + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: nil, + event: "mount", + assigns: presentation_assigns(new_socket) + }) + + result + end + + def handle_event(event, params, socket) + when event in ~w(validate save generate_random) do + # The UI socket lags behind the real state during animation; apply pending + # assigns so the event handler sees up-to-date data. + effective_socket = + case socket.assigns[:pending_cur] do + nil -> socket + pending -> assign(socket, Map.to_list(pending)) + end + + {:noreply, updated} = FormDemoLocal.handle_event(event, params, effective_socket) + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: event, + event: event, + assigns: presentation_assigns(updated) + }) + + new_pending = %{ + form: updated.assigns.form, + disabled: updated.assigns.disabled, + users: updated.assigns.users + } + + {:noreply, + socket + |> assign(:pending_prev, socket.assigns[:pending_cur]) + |> assign(:pending_cur, new_pending)} + end + + def handle_info({:js_push, "llv_ack", _}, socket) do + case {socket.assigns[:pending_prev], socket.assigns[:pending_cur]} do + {%{form: form, disabled: disabled, users: users}, _} -> + {:noreply, assign(socket, form: form, disabled: disabled, users: users, pending_prev: nil)} + + {nil, %{form: form, disabled: disabled, users: users}} -> + {:noreply, assign(socket, form: form, disabled: disabled, users: users, pending_cur: nil)} + + _ -> + {:noreply, socket} + end + end + + def handle_event(event, params, socket) do + FormDemoLocal.handle_event(event, params, socket) + end + + defp presentation_assigns(socket) do + assigns = socket.assigns + form_params = assigns.form.params + + %{ + username: Map.get(form_params, "username", ""), + email: Map.get(form_params, "email", ""), + disabled: assigns.disabled, + users: length(assigns.users) + } + end +end diff --git a/examples/local-lv-forms/local/mix.exs b/examples/local-lv-forms/local/mix.exs index c01e086e..8ac80547 100644 --- a/examples/local-lv-forms/local/mix.exs +++ b/examples/local-lv-forms/local/mix.exs @@ -7,6 +7,7 @@ defmodule Local.MixProject do version: "0.1.0", elixir: "~> 1.17", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(), deps: deps(), compilers: Mix.compilers(), aliases: aliases() @@ -17,6 +18,17 @@ defmodule Local.MixProject do [default_target: :wasm] end + # lib_landing contains presentation wrappers that emit WASM events for the + # landing page animation demos. They are compiled only when building for that + # context so normal development builds stay unaffected. + defp elixirc_paths do + if System.get_env("LLV_LANDING_PATCH") == "true" do + ["lib", "lib_landing"] + else + ["lib"] + end + end + def application do [ extra_applications: [:logger], diff --git a/examples/local-lv-forms/local/mix.lock b/examples/local-lv-forms/local/mix.lock index ff9df1e4..9459cda6 100644 --- a/examples/local-lv-forms/local/mix.lock +++ b/examples/local-lv-forms/local/mix.lock @@ -1,5 +1,5 @@ %{ - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, @@ -8,7 +8,7 @@ "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, diff --git a/examples/local-lv-forms/mix.lock b/examples/local-lv-forms/mix.lock index 68c75732..b83f05e6 100644 --- a/examples/local-lv-forms/mix.lock +++ b/examples/local-lv-forms/mix.lock @@ -1,7 +1,7 @@ %{ "bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, - "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, "elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"}, @@ -16,7 +16,7 @@ "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, diff --git a/examples/local-lv-thermostat/local/lib/thermostat_live.ex b/examples/local-lv-thermostat/local/lib/thermostat_live.ex index 2616da43..e1454e9d 100644 --- a/examples/local-lv-thermostat/local/lib/thermostat_live.ex +++ b/examples/local-lv-thermostat/local/lib/thermostat_live.ex @@ -1,27 +1,25 @@ defmodule ThermostatLive do use LocalLiveView + @default_temperature 25 + @default_country "Poland" + def render(assigns) do ~H""" -

      Current temperature: {@temperature}°C

      -
      - - +
      +

      Current temperature

      +

      {@temperature}°C

      +
      + + +
      +

      Country: {@country}

      -

      Country: {@country}

      """ end def mount(_params, _session, socket) do - temperature = 25 - country = "Poland" - - socket = - socket - |> assign(:temperature, temperature) - |> assign(:country, country) - - {:ok, socket} + {:ok, socket |> assign(:temperature, @default_temperature) |> assign(:country, @default_country)} end def handle_event("inc_temperature", _params, socket) do @@ -31,4 +29,5 @@ defmodule ThermostatLive do def handle_event("dec_temperature", _params, socket) do {:noreply, update(socket, :temperature, &(&1 - 1))} end + end diff --git a/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex b/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex new file mode 100644 index 00000000..0fa0502d --- /dev/null +++ b/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex @@ -0,0 +1,71 @@ +defmodule ThermostatLivePresentation do + use LocalLiveView + + defdelegate render(assigns), to: ThermostatLive + + def mount(params, session, socket) do + result = ThermostatLive.mount(params, session, socket) + {:ok, new_socket} = result + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: nil, + event: "mount", + assigns: presentation_assigns(new_socket) + }) + + result + end + + def handle_event(event, params, socket) + when event in ~w(inc_temperature dec_temperature) do + # The UI socket lags behind the real state during animation; apply pending + # assigns so the event handler sees up-to-date data. + effective_socket = + case socket.assigns[:pending_cur] do + nil -> socket + pending -> assign(socket, Map.to_list(pending)) + end + + {:noreply, updated} = ThermostatLive.handle_event(event, params, effective_socket) + + Popcorn.Wasm.send_event("llv_presentation", %{ + block: event, + event: event, + assigns: presentation_assigns(updated) + }) + + new_pending = %{ + temperature: updated.assigns.temperature, + country: updated.assigns.country + } + + {:noreply, + socket + |> assign(:pending_prev, socket.assigns[:pending_cur]) + |> assign(:pending_cur, new_pending)} + end + + def handle_info({:js_push, "llv_ack", _}, socket) do + case {socket.assigns[:pending_prev], socket.assigns[:pending_cur]} do + {%{temperature: t, country: c}, _} -> + {:noreply, assign(socket, temperature: t, country: c, pending_prev: nil)} + + {nil, %{temperature: t, country: c}} -> + {:noreply, assign(socket, temperature: t, country: c, pending_cur: nil)} + + _ -> + {:noreply, socket} + end + end + + def handle_event(event, params, socket) do + ThermostatLive.handle_event(event, params, socket) + end + + defp presentation_assigns(socket) do + %{ + temperature: socket.assigns.temperature, + country: socket.assigns.country + } + end +end diff --git a/examples/local-lv-thermostat/local/mix.exs b/examples/local-lv-thermostat/local/mix.exs index 539d0e84..32501c5c 100644 --- a/examples/local-lv-thermostat/local/mix.exs +++ b/examples/local-lv-thermostat/local/mix.exs @@ -7,6 +7,7 @@ defmodule Local.MixProject do version: "0.1.0", elixir: "~> 1.17", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(), deps: deps(), compilers: Mix.compilers(), aliases: aliases() @@ -24,6 +25,14 @@ defmodule Local.MixProject do ] end + defp elixirc_paths do + if System.get_env("LLV_LANDING_PATCH") == "true" do + ["lib", "lib_landing"] + else + ["lib"] + end + end + defp deps do [ {:local_live_view, path: "../../../local-live-view"} diff --git a/landing-page/astro.config.mjs b/landing-page/astro.config.mjs index b6832cb6..432d5a02 100644 --- a/landing-page/astro.config.mjs +++ b/landing-page/astro.config.mjs @@ -54,12 +54,14 @@ export default defineConfig({ "../examples/local-lv-thermostat/priv/static/assets/js/wasm", dir: "../examples/local-lv-thermostat", newBundleName: "local_thermostat.avm", + extraEnv: { LLV_LANDING_PATCH: "true" }, }), buildBundle({ wasmSrcPathDefault: "../examples/local-lv-forms/priv/static/assets/js/wasm", dir: "../examples/local-lv-forms", newBundleName: "local_forms.avm", + extraEnv: { LLV_LANDING_PATCH: "true" }, }), ], markdown: { diff --git a/landing-page/build-wasm.js b/landing-page/build-wasm.js index 77ca2d71..9230c1f9 100644 --- a/landing-page/build-wasm.js +++ b/landing-page/build-wasm.js @@ -4,9 +4,9 @@ import { dirname, join } from "path"; import { fileURLToPath } from "url"; /** - * @param {{ dir: string, wasmSrcPathDefault?: string, newBundleName: string }} options + * @param {{ dir: string, wasmSrcPathDefault?: string, newBundleName: string, extraEnv?: Record }} options */ -export function buildBundle({ dir, wasmSrcPathDefault, newBundleName }) { +export function buildBundle({ dir, wasmSrcPathDefault, newBundleName, extraEnv = {} }) { return { name: "build-bundle", hooks: { @@ -15,7 +15,7 @@ export function buildBundle({ dir, wasmSrcPathDefault, newBundleName }) { const wasmSrcPath = wasmSrcPathDefault ?? join(dir, "dist", "wasm"); const wasmDestPath = wasmDir(config); - await run("mix", ["build"], { dir }); + await run("mix", ["build"], { dir, env: extraEnv }); const srcFiles = await readdir(wasmSrcPath); const [avm] = srcFiles.filter((path) => path.endsWith(".avm")); @@ -53,13 +53,14 @@ function wasmDir(config) { return join(publicPath, "wasm"); } -function run(cmd, args, { dir }) { +function run(cmd, args, { dir, env = {} }) { const strCmd = `${cmd} ${args.join(" ")}`; return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd: dir, stdio: "inherit", + env: { ...process.env, ...env }, }); child.on("close", (code) => { diff --git a/landing-page/package.json b/landing-page/package.json index 78a525e8..58cc64d8 100644 --- a/landing-page/package.json +++ b/landing-page/package.json @@ -13,18 +13,20 @@ "@fontsource-variable/handjet": "^5.2.6", "@fontsource-variable/inter": "^5.2.6", "@iconify-json/material-symbols-light": "^1.2.30", + "@nanostores/react": "^1.1.0", "@swmansion/popcorn": "workspace:*", "@tailwindcss/vite": "^4.1.11", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", - "ghostty-web": "^0.4.0", "astro": "^6.0.6", "astro-icon": "^1.1.5", "astro-mermaid": "^1.3.1", "chokidar": "^4.0.3", + "ghostty-web": "^0.4.0", "highlight.js": "^11.11.1", "local_live_view": "workspace:*", "mermaid": "^11.7.0", + "nanostores": "^1.3.0", "phoenix": "^1.8.0", "phoenix_live_view": "^1.1.0", "playwright": "^1.60.0", @@ -33,7 +35,9 @@ "rehype-mermaid": "^3.0.0", "sharp": "^0.34.5", "tailwindcss": "^4.1.11", - "three": "^0.178.0" + "three": "^0.178.0", + "tree-sitter": "^0.25.0", + "tree-sitter-elixir": "^0.3.5" }, "devDependencies": { "@types/phoenix": "^1.6.7", @@ -43,5 +47,12 @@ "prettier-plugin-tailwindcss": "^0.6.14", "vite": "catalog:", "vite-plugin-devtools-json": "^0.2.1" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "sharp", + "tree-sitter", + "tree-sitter-elixir" + ] } } diff --git a/landing-page/src/components/local-live-view/AssignsPanel.tsx b/landing-page/src/components/local-live-view/AssignsPanel.tsx new file mode 100644 index 00000000..ee162b61 --- /dev/null +++ b/landing-page/src/components/local-live-view/AssignsPanel.tsx @@ -0,0 +1,32 @@ +import { useStore } from "@nanostores/react"; +import { $assigns, $flashKeys } from "../../scripts/local-live-view/store"; +import { PanelLayout } from "./PanelLayout"; + +export function AssignsPanel() { + const assigns = useStore($assigns); + const flashKeys = useStore($flashKeys); + const entries = Object.entries(assigns); + + return ( + +
      + %{ + {entries.map(([k, v], i) => ( +
      +    + {k}:{" "} + + {typeof v === "string" ? `"${v}"` : String(v)} + + {i < entries.length - 1 && ( + , + )} +
      + ))} + } +
      +
      + ); +} diff --git a/landing-page/src/components/local-live-view/EventLog.tsx b/landing-page/src/components/local-live-view/EventLog.tsx new file mode 100644 index 00000000..be811de2 --- /dev/null +++ b/landing-page/src/components/local-live-view/EventLog.tsx @@ -0,0 +1,25 @@ +import { useStore } from "@nanostores/react"; +import { $log } from "../../scripts/local-live-view/store"; +import "./llv-scrollbar.css"; +import { PanelLayout } from "./PanelLayout"; + +export function EventLog() { + const log = useStore($log); + if (log.length === 0) return null; + + return ( + +
      + {log.map((entry) => ( +
      + {entry.event} + {entry.result} +
      + ))} +
      +
      + ); +} diff --git a/landing-page/src/components/local-live-view/LifecycleStrip.tsx b/landing-page/src/components/local-live-view/LifecycleStrip.tsx new file mode 100644 index 00000000..a1368428 --- /dev/null +++ b/landing-page/src/components/local-live-view/LifecycleStrip.tsx @@ -0,0 +1,34 @@ +import { useStore } from "@nanostores/react"; +import { $step } from "../../scripts/local-live-view/store"; + +const STEPS = [ + "phx-event", + "handle_event", + "update assigns", + "re-render", +]; + +export function LifecycleStrip() { + const step = useStore($step); + + return ( +
      + {STEPS.map((s, i) => ( + + + {s} + + {i < STEPS.length - 1 && ( + + → + + )} + + ))} +
      + ); +} diff --git a/landing-page/src/components/local-live-view/LlvCodePanel.astro b/landing-page/src/components/local-live-view/LlvCodePanel.astro new file mode 100644 index 00000000..8ed96f4d --- /dev/null +++ b/landing-page/src/components/local-live-view/LlvCodePanel.astro @@ -0,0 +1,219 @@ +--- +import { Code } from "astro:components"; +import type { ShikiTransformer } from "shiki"; +import { createBlockTransformer } from "../../scripts/local-live-view/code-panel"; +import { popcornTheme } from "../../scripts/local-live-view/theme"; +import "./llv-scrollbar.css"; + +export interface CodeTab { + label: string; + id: string; + code: string; + lang: string; +} + +interface Props { + tabs: CodeTab[]; +} + +const { tabs } = Astro.props; + +const transformers: ShikiTransformer[] = tabs.map((tab) => + createBlockTransformer(tab.code), +); +--- + +
      +
      + { + tabs.map((tab, i) => ( + + )) + } +
      + { + tabs.map((tab, i) => ( +
      + +
      + )) + } +
      + + + + diff --git a/landing-page/src/components/local-live-view/LlvDemoLayout.astro b/landing-page/src/components/local-live-view/LlvDemoLayout.astro new file mode 100644 index 00000000..bb9539ac --- /dev/null +++ b/landing-page/src/components/local-live-view/LlvDemoLayout.astro @@ -0,0 +1,34 @@ +
      +
      + +
      + +
      + + diff --git a/landing-page/src/components/local-live-view/PanelLayout.tsx b/landing-page/src/components/local-live-view/PanelLayout.tsx new file mode 100644 index 00000000..8df0be1c --- /dev/null +++ b/landing-page/src/components/local-live-view/PanelLayout.tsx @@ -0,0 +1,19 @@ +import type { PropsWithChildren } from "react"; + +type PanelHeaderProps = { + title: string; +}; + +export function PanelLayout({ + children, + title, +}: PropsWithChildren) { + return ( +
      +

      + {title} +

      + {children} +
      + ); +} diff --git a/landing-page/src/components/local-live-view/llv-scrollbar.css b/landing-page/src/components/local-live-view/llv-scrollbar.css new file mode 100644 index 00000000..03198471 --- /dev/null +++ b/landing-page/src/components/local-live-view/llv-scrollbar.css @@ -0,0 +1,24 @@ +.llv-scroll, +.llv-scroll pre { + scrollbar-color: var(--color-llv-brown-50) var(--color-llv-gutter); + scrollbar-width: thin; +} +.llv-scroll::-webkit-scrollbar, +.llv-scroll pre::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.llv-scroll::-webkit-scrollbar-track, +.llv-scroll pre::-webkit-scrollbar-track { + background: var(--color-llv-gutter); + border-radius: 3px; +} +.llv-scroll::-webkit-scrollbar-thumb, +.llv-scroll pre::-webkit-scrollbar-thumb { + background: var(--color-llv-brown-50); + border-radius: 3px; +} +.llv-scroll::-webkit-scrollbar-thumb:hover, +.llv-scroll pre::-webkit-scrollbar-thumb:hover { + background: #ef7c00; +} diff --git a/landing-page/src/pages/demos/local-forms.astro b/landing-page/src/pages/demos/local-forms.astro index f42b379c..676d9d2d 100644 --- a/landing-page/src/pages/demos/local-forms.astro +++ b/landing-page/src/pages/demos/local-forms.astro @@ -1,13 +1,36 @@ --- import Layout from "../../layouts/Layout.astro"; import Section from "../../components/Section.astro"; +import LlvDemoLayout from "../../components/local-live-view/LlvDemoLayout.astro"; +import { AssignsPanel } from "../../components/local-live-view/AssignsPanel"; +import { LifecycleStrip } from "../../components/local-live-view/LifecycleStrip"; +import { EventLog } from "../../components/local-live-view/EventLog"; +import LlvCodePanel from "../../components/local-live-view/LlvCodePanel.astro"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { CodeTab } from "../../components/local-live-view/LlvCodePanel.astro"; +import "../../styles/demos/local-forms.css"; + +const libDir = path.resolve( + fileURLToPath(import.meta.url), + "../../../../..", + "examples/local-lv-forms/local/lib", +); +const formCode = fs + .readFileSync(path.join(libDir, "form_demo_local.ex"), "utf8") + .trim(); + +const tabs: CodeTab[] = [ + { label: "FormDemoLocal", id: "form", code: formCode, lang: "elixir" }, +]; ---

      Local Forms

      @@ -21,245 +44,145 @@ import Section from "../../components/Section.astro"; correct format while typing in the form and on form submission.

      - -
      -
      -

      - Add New User -

      -
      -
      -
      -
      -
      -
      -      
      -defmodule FormDemoLocal do
      -  use LocalLiveView
      -  import Local.CoreComponents
      -
      -  @impl true
      -  def render(assigns) do
      -    ~H"""
      -    <div class="bordered">
      -      <.form for={@form} id="my-form" phx-change="validate" phx-submit="save">
      -        <label>USERNAME</label>
      -        <.input type="text" field={@form[:username]} />
      -        <label>EMAIL</label>
      -        <.input type="text" field={@form[:email]} />
      -        <div class="centered">
      -          <button class="ghost-button" disabled={@disabled}>SAVE</button>
      -        </div>
      -      </.form>
      -      <div class="centered">
      -        <button class="ghost-button" phx-click="generate_random">GENERATE RANDOM</button>
      -      </div>
      -    </div>
      -    <%= for error <- @errors do %>
      -      <p style="color:red;">{error}</p>
      -    <% end %>
      -    <div class="bordered">
      -      <h1>[Local Runtime] User List:</h1>
      -      <ul>
      -        <%= for user <- @users do %>
      -          <li>Username: {user["username"]}, Email: {user["email"]}</li>
      -        <% end %>
      -      </ul>
      -    </div>
      -    """
      -  end
      -
      -  @impl true
      -  def mount(_params, _session, socket) do
      -    send(self(), :sync)
      -    user = %{"email" => "", "username" => ""}
      -    {:ok, assign(socket, users: [], form: to_form(user), errors: [], disabled: true)}
      -  end
      -
      -  @impl true
      -  def handle_event("validate", params, socket) do
      -    errors = validate(params, socket.assigns.users)
      -    {:noreply, assign(socket, form: to_form(params), errors: errors, disabled: errors != [])}
      -  end
      -
      -  def handle_event("save", user_params, socket) do
      -    users = socket.assigns.users
      -
      -    case validate(user_params, users) do
      -      [] ->
      -        blank_user = %{"email" => "", "username" => ""}
      -        send_to_phoenix(%{"type" => "new_user", "user" => user_params})
      -
      -        {:noreply,
      -         assign(socket,
      -           form: to_form(blank_user),
      -           users: users ++ [user_params],
      -           errors: [],
      -           disabled: true
      -         )}
      -
      -      errors ->
      -        {:noreply, assign(socket, errors: errors, disabled: true)}
      -    end
      -  end
      -
      -  def handle_event(
      -        "llv_server_message",
      -        %{"type" => "synchronize", "users" => server_users},
      -        socket
      -      ) do
      -    filtered_users =
      -      Enum.filter(socket.assigns.users, fn user ->
      -        case validate_already_existing(user, server_users) do
      -          [] ->
      -            send_to_phoenix(%{"type" => "new_user", "user" => user})
      -            true
      -
      -          _ ->
      -            false
      -        end
      -      end)
       
      -    {:noreply, assign(socket, users: server_users ++ filtered_users)}
      -  end
      -
      -  def handle_event("generate_random", _params, socket) do
      -    users = socket.assigns.users
      -    user = generate_random_user(users)
      -    handle_event("save", user, socket)
      -  end
      -
      -  defp validate(user, existing_users) do
      -    (validate_correctness(user) ++ validate_already_existing(user, existing_users))
      -    |> Enum.filter(fn error -> error != "" end)
      -  end
      -
      -  defp validate_already_existing(user, existing_users) do
      -    user
      -    |> Enum.filter(fn {key, value} ->
      -      Enum.any?(existing_users, fn user -> Map.get(user, key) == value end)
      -    end)
      -    |> Enum.map(fn {key, _value} ->
      -      String.capitalize("#{key} already in use")
      -    end)
      -  end
      -
      -  defp validate_correctness(user) do
      -    Enum.map(user, fn {key, value} -> validate_correctness(key, value) end)
      -  end
      -
      -  defp validate_correctness("username", value) do
      -    cond do
      -      String.length(value) < 4 -> "Username length must be greater than 3 characters"
      -      true -> ""
      -    end
      -  end
      -
      -  defp validate_correctness("email", value) do
      -    with [name, server] <- String.split(value, "@"),
      -         true <- String.length(name) > 0 and String.contains?(server, ".") do
      -      ""
      -    else
      -      _err -> "Email must have an email format"
      -    end
      -  end
      -end
      -      
      -      
      -
      + + +
      +
      + + + +
      + + +
      - - -