From a192ef816a46f6dd9fee19c5cb6724780c3a0624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:30:23 +0200 Subject: [PATCH 1/8] fix: update eventHandler JSDoc to reflect popcorn handler signature --- local-live-view/assets/local_live_view.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/local-live-view/assets/local_live_view.js b/local-live-view/assets/local_live_view.js index 291fd51e..5eb13938 100644 --- a/local-live-view/assets/local_live_view.js +++ b/local-live-view/assets/local_live_view.js @@ -5,7 +5,8 @@ import { Popcorn } from "@swmansion/popcorn"; * @property {typeof import("phoenix").Socket} [Socket] - The Phoenix Socket class (required when using mirror channels). * @property {string[]} [bundlePaths] - Paths to the compiled WASM bundle files. Defaults to `["wasm/bundle.avm"]`. * @property {boolean} [debug] - Enable Popcorn debug logging. Defaults to `false`. - * @property {(msg: unknown) => void} [eventHandler] - Optional callback for raw Popcorn messages. + * @property {(eventName: string, payload: unknown) => void} [eventHandler] - Optional callback for raw Popcorn messages. + */ export class LLVEngine { From a3f7b79a3b7f897ce213b90a6e29be8cff29de2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:31:09 +0200 Subject: [PATCH 2/8] deps: add nanostores, tree-sitter packages; update pnpm config --- landing-page/package.json | 15 +++++- package.json | 9 +++- pnpm-lock.yaml | 109 +++++++++++++++++++++++--------------- 3 files changed, 87 insertions(+), 46 deletions(-) diff --git a/landing-page/package.json b/landing-page/package.json index 8232c400..06e14630 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.53.2", @@ -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/package.json b/package.json index 18a57736..41f4107f 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,12 @@ "version": "0.1.0", "description": "Monorepo root", "scripts": {}, - "packageManager": "pnpm@11.0.8" + "packageManager": "pnpm@10.28.1", + "pnpm": { + "onlyBuiltDependencies": [ + "sharp", + "tree-sitter", + "tree-sitter-elixir" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 653fad6b..13a11e55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,9 @@ importers: '@iconify-json/material-symbols-light': specifier: ^1.2.30 version: 1.2.70 + '@nanostores/react': + specifier: ^1.1.0 + version: 1.1.0(nanostores@1.3.0)(react@19.2.6) '@swmansion/popcorn': specifier: workspace:* version: link:../popcorn/js @@ -206,6 +209,9 @@ importers: mermaid: specifier: ^11.7.0 version: 11.14.0 + nanostores: + specifier: ^1.3.0 + version: 1.3.0 phoenix: specifier: ^1.8.0 version: 1.8.7 @@ -233,6 +239,12 @@ importers: three: specifier: ^0.178.0 version: 0.178.0 + tree-sitter: + specifier: ^0.25.0 + version: 0.25.0 + tree-sitter-elixir: + specifier: ^0.3.5 + version: 0.3.5(tree-sitter@0.25.0) devDependencies: '@types/phoenix': specifier: ^1.6.7 @@ -1268,105 +1280,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1440,6 +1436,13 @@ packages: '@mermaid-js/parser@1.1.0': resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@nanostores/react@1.1.0': + resolution: {integrity: sha512-MbH35fjhcf7LAubYX5vhOChYUfTLzNLqH/mBGLVsHkcvjy0F8crO1WQwdmQ2xKbAmtpalDa2zBt3Hlg5kqr8iw==} + engines: {node: ^20.0.0 || >=22.0.0} + peerDependencies: + nanostores: ^1.2.0 + react: '>=18.0.0' + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -1492,42 +1495,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -1617,79 +1614,66 @@ packages: resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.3': resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.3': resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.3': resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.3': resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.3': resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.3': resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.3': resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.3': resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.3': resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.3': resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.3': resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.3': resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.3': resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} @@ -1905,28 +1889,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -3332,28 +3312,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -3635,6 +3611,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.3.0: + resolution: {integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==} + engines: {node: ^20.0.0 || >=22.0.0} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3648,9 +3628,20 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-addon-api@8.7.0: + resolution: {integrity: sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==} + engines: {node: ^18 || ^20 || >= 21} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-mock-http@1.0.4: resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} @@ -4192,6 +4183,14 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tree-sitter-elixir@0.3.5: + resolution: {integrity: sha512-xozQMvYK0aSolcQZAx2d84Xe/YMWFuRPYFlLVxO01bM2GITh5jyiIp0TqPCQa8754UzRAI7A83hZmfiYub5TZQ==} + peerDependencies: + tree-sitter: ^0.21.0 + + tree-sitter@0.25.0: + resolution: {integrity: sha512-PGZZzFW63eElZJDe/b/R/LbsjDDYJa5UEjLZJB59RQsMX+fo0j54fqBPn1MGKav/QNa0JR0zBiVaikYDWCj5KQ==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5448,6 +5447,11 @@ snapshots: dependencies: langium: 4.2.3 + '@nanostores/react@1.1.0(nanostores@1.3.0)(react@19.2.6)': + dependencies: + nanostores: 1.3.0 + react: 19.2.6 + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8221,6 +8225,8 @@ snapshots: nanoid@3.3.12: {} + nanostores@1.3.0: {} + natural-compare@1.4.0: {} neotraverse@0.6.18: {} @@ -8234,8 +8240,14 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-addon-api@7.1.1: {} + + node-addon-api@8.7.0: {} + node-fetch-native@1.6.7: {} + node-gyp-build@4.8.4: {} + node-mock-http@1.0.4: {} node-releases@2.0.38: {} @@ -8857,6 +8869,17 @@ snapshots: tinyrainbow@3.1.0: {} + tree-sitter-elixir@0.3.5(tree-sitter@0.25.0): + dependencies: + node-addon-api: 7.1.1 + node-gyp-build: 4.8.4 + tree-sitter: 0.25.0 + + tree-sitter@0.25.0: + dependencies: + node-addon-api: 8.7.0 + node-gyp-build: 4.8.4 + trim-lines@3.0.1: {} trough@2.2.0: {} From d68ef2a5bb8cdb2cb84ad3729119924afd7513cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:31:48 +0200 Subject: [PATCH 3/8] feat: add extraEnv option to buildBundle, pass LLV_LANDING_PATCH --- landing-page/astro.config.mjs | 2 ++ landing-page/build-wasm.js | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) 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) => { From 00358a8d5cf17b34dba2a566684d8c899f092c4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:32:26 +0200 Subject: [PATCH 4/8] feat: add lib_landing conditional compilation + presentation modules --- .../form_demo_local_presentation.ex | 89 +++++++++++++++++++ examples/local-lv-forms/local/mix.exs | 9 ++ .../thermostat_live_presentation.ex | 78 ++++++++++++++++ examples/local-lv-thermostat/local/mix.exs | 9 ++ 4 files changed, 185 insertions(+) create mode 100644 examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex create mode 100644 examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex 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..6b36453a --- /dev/null +++ b/examples/local-lv-forms/local/lib_landing/form_demo_local_presentation.ex @@ -0,0 +1,89 @@ +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 ["validate", "save", "generate_random"] do + 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, + errors: updated.assigns.errors, + 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, errors: errors, disabled: disabled, users: users}, _} -> + {:noreply, + socket + |> assign(:form, form) + |> assign(:errors, errors) + |> assign(:disabled, disabled) + |> assign(:users, users) + |> assign(:_pending_prev, nil)} + + {nil, %{form: form, errors: errors, disabled: disabled, users: users}} -> + {:noreply, + socket + |> assign(:form, form) + |> assign(:errors, errors) + |> assign(:disabled, disabled) + |> assign(:users, users) + |> assign(:_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", ""), + errors: length(assigns.errors), + 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..57b5d19f 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,14 @@ defmodule Local.MixProject do [default_target: :wasm] end + 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-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..af9c54d1 --- /dev/null +++ b/examples/local-lv-thermostat/local/lib_landing/thermostat_live_presentation.ex @@ -0,0 +1,78 @@ +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 ["inc_temperature", "dec_temperature"] do + + 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, + socket + |> assign(:temperature, t) + |> assign(:country, c) + |> assign(:_pending_prev, nil)} + + {nil, %{temperature: t, country: c}} -> + {:noreply, + socket + |> assign(:temperature, t) + |> assign(:country, c) + |> assign(:_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"} From adee2576252cefe787b192e80e242cdf896a0cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:32:58 +0200 Subject: [PATCH 5/8] feat: update forms and thermostat UI for landing presentation --- examples/local-lv-forms/assets/css/app.css | 2 +- .../controllers/page_html/home.html.heex | 2 +- .../lib/form_demo_web/live/form_demo_live.ex | 2 +- .../local/lib/form_demo_local.ex | 30 +++++++++++-------- .../local/lib/thermostat_live.ex | 24 +++++++-------- 5 files changed, 31 insertions(+), 29 deletions(-) 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 3d06c2e8..7517bc00 100644 --- a/examples/local-lv-forms/local/lib/form_demo_local.ex +++ b/examples/local-lv-forms/local/lib/form_demo_local.ex @@ -5,25 +5,31 @@ defmodule FormDemoLocal do @impl true def render(assigns) do ~H""" +

    Add new user

    <.form for={@form} id="my-form" 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" /> +
    + +
    -
    - -
    - <%= for error <- @errors do %> -

    {error}

    - <% end %> -
    -

    [Local Runtime] User List:

    +
    + <%= for error <- @errors do %> +

    {error}

    + <% end %> +
    +
    +

    [Local Runtime] User List:

    + <%= if @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-thermostat/local/lib/thermostat_live.ex b/examples/local-lv-thermostat/local/lib/thermostat_live.ex index 2616da43..c0b35b28 100644 --- a/examples/local-lv-thermostat/local/lib/thermostat_live.ex +++ b/examples/local-lv-thermostat/local/lib/thermostat_live.ex @@ -3,25 +3,20 @@ defmodule ThermostatLive do 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, 25) |> assign(:country, "Poland")} end def handle_event("inc_temperature", _params, socket) do @@ -31,4 +26,5 @@ defmodule ThermostatLive do def handle_event("dec_temperature", _params, socket) do {:noreply, update(socket, :temperature, &(&1 - 1))} end + end From e3ebc3b90cfc83cac8532d331420ec16c1b6e365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:33:51 +0200 Subject: [PATCH 6/8] feat: add LLV demo components, scripts, and style --- .../local-live-view/AssignsPanel.tsx | 32 +++ .../components/local-live-view/EventLog.tsx | 25 ++ .../local-live-view/LifecycleStrip.tsx | 34 +++ .../local-live-view/LlvCodePanel.astro | 217 ++++++++++++++++++ .../local-live-view/LlvDemoLayout.astro | 34 +++ .../local-live-view/PanelLayout.tsx | 19 ++ .../local-live-view/llv-scrollbar.css | 24 ++ .../src/scripts/local-live-view/animation.ts | 114 +++++++++ .../src/scripts/local-live-view/code-panel.ts | 135 +++++++++++ .../src/scripts/local-live-view/store.ts | 23 ++ .../src/scripts/local-live-view/theme.ts | 83 +++++++ landing-page/src/styles/demos/local-forms.css | 85 +++++++ .../src/styles/demos/local-thermostat.css | 54 +++++ landing-page/src/styles/global.css | 28 +++ 14 files changed, 907 insertions(+) create mode 100644 landing-page/src/components/local-live-view/AssignsPanel.tsx create mode 100644 landing-page/src/components/local-live-view/EventLog.tsx create mode 100644 landing-page/src/components/local-live-view/LifecycleStrip.tsx create mode 100644 landing-page/src/components/local-live-view/LlvCodePanel.astro create mode 100644 landing-page/src/components/local-live-view/LlvDemoLayout.astro create mode 100644 landing-page/src/components/local-live-view/PanelLayout.tsx create mode 100644 landing-page/src/components/local-live-view/llv-scrollbar.css create mode 100644 landing-page/src/scripts/local-live-view/animation.ts create mode 100644 landing-page/src/scripts/local-live-view/code-panel.ts create mode 100644 landing-page/src/scripts/local-live-view/store.ts create mode 100644 landing-page/src/scripts/local-live-view/theme.ts create mode 100644 landing-page/src/styles/demos/local-forms.css create mode 100644 landing-page/src/styles/demos/local-thermostat.css 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..19b72892 --- /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..468284da --- /dev/null +++ b/landing-page/src/components/local-live-view/LlvCodePanel.astro @@ -0,0 +1,217 @@ +--- +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/scripts/local-live-view/animation.ts b/landing-page/src/scripts/local-live-view/animation.ts new file mode 100644 index 00000000..c58eee41 --- /dev/null +++ b/landing-page/src/scripts/local-live-view/animation.ts @@ -0,0 +1,114 @@ +import { $assigns, $flashKeys, $step } from "./store"; + +function isLineVisible(el: HTMLElement): boolean { + const { top } = el.getBoundingClientRect(); + return top >= 0 && top < window.innerHeight; +} + +function isElementInView(el: HTMLElement): boolean { + const { top, bottom } = el.getBoundingClientRect(); + return top < window.innerHeight && bottom > 0; +} + +let flashTimer: ReturnType | null = null; + +export function applyAssignsWithFlash(newAssigns: Record) { + const prev = $assigns.get(); + const changed = new Set( + Object.keys(newAssigns).filter((k) => prev[k] !== newAssigns[k]), + ); + $assigns.set(newAssigns); + if (changed.size > 0) { + if (flashTimer !== null) clearTimeout(flashTimer); + $flashKeys.set(changed); + flashTimer = setTimeout(() => { + $flashKeys.set(new Set()); + flashTimer = null; + }, 800); + } +} + +export function setCodeHighlight( + containerId: string, + activeBlock: string | null, +) { + const container = document.getElementById(containerId); + if (!container) return; + + const firstLine = container.querySelector(`.line[data-block="${activeBlock}"]`); + if (firstLine && !isLineVisible(firstLine)) { + firstLine.scrollIntoView({ behavior: "smooth" }); + } + container.querySelectorAll(".line").forEach((line) => { + const b = line.dataset.block; + if (!activeBlock) { + line.classList.remove("hl", "dim"); + } else if (b === activeBlock) { + line.classList.add("hl"); + line.classList.remove("dim"); + } else { + line.classList.add("dim"); + line.classList.remove("hl"); + } + }); +} + +export function highlightHtml(key: string, state: boolean) { + const el = document.querySelector(`[data-value="${key}"]`); + if (!el) return; + + const container = el.closest("[data-pop-view]") ?? el; + const needsScroll = state && !isElementInView(container); + container.classList.toggle(`hl-${key}`, state); + if (needsScroll) container.scrollIntoView({ behavior: "smooth" }); +} + +export type StepDef = { + delay: number; + onEnter?: (ctx: T) => void; +}; + +export type AnimationOptions = { + completeDelay: number; + onComplete?: (ctx: T) => void; + onCancel?: (ctx: T) => void; +}; + +export function createAnimation( + steps: StepDef[], + options: AnimationOptions, +): { run: (ctx: T) => void; cancel: (ctx: T) => void } { + let timers: ReturnType[] = []; + let runningCtx: T | null = null; + + function cancelInternal() { + if (timers.length === 0) return; + timers.forEach(clearTimeout); + timers = []; + if (runningCtx !== null) options.onCancel?.(runningCtx); + runningCtx = null; + } + + function run(ctx: T) { + cancelInternal(); + runningCtx = ctx; + steps.forEach(({ delay, onEnter }, i) => { + timers.push( + setTimeout(() => { + $step.set(i); + onEnter?.(ctx); + }, delay), + ); + }); + timers.push( + setTimeout(() => { + timers = []; + runningCtx = null; + options.onComplete?.(ctx); + $step.set(null); + }, options.completeDelay), + ); + } + + return { run, cancel: cancelInternal }; +} diff --git a/landing-page/src/scripts/local-live-view/code-panel.ts b/landing-page/src/scripts/local-live-view/code-panel.ts new file mode 100644 index 00000000..5264a1bb --- /dev/null +++ b/landing-page/src/scripts/local-live-view/code-panel.ts @@ -0,0 +1,135 @@ +import type { ShikiTransformer } from "shiki"; + +import Parser from "tree-sitter"; +import Elixir from "tree-sitter-elixir"; + +const parser = new Parser(); +parser.setLanguage(Elixir); + +export type LineAnnotation = { block: string | null; defpGroup?: string; defpHeader?: boolean }; + +export function parseElixirBlocks(code: string): LineAnnotation[] { + const tree = parser.parse(code); + const lineCount = code.split("\n").length; + const annotations: LineAnnotation[] = Array.from( + { length: lineCount }, + () => ({ + block: null, + }), + ); + + const defpRanges: [number, number][] = []; + walkNode(tree.rootNode, annotations, defpRanges); + + defpRanges.sort((a, b) => a[0] - b[0]); + defpRanges.forEach(([start, end], i) => { + const groupId = `defp-${i}`; + annotations[start] = { ...annotations[start], defpGroup: groupId, defpHeader: true }; + for (let row = start + 1; row <= end; row++) { + annotations[row] = { ...annotations[row], defpGroup: groupId }; + } + }); + + return annotations; +} + +function walkNode(node: Parser.SyntaxNode, annotations: LineAnnotation[], defpRanges: [number, number][]) { + if (node.type !== "call") { + for (const child of node.children) walkNode(child, annotations, defpRanges); + return; + } + + const keyword = node.children[0]?.text as string | undefined; + + if (keyword === "defmodule") { + annotations[node.startPosition.row] = { block: "module" }; + annotations[node.endPosition.row] = { block: "module" }; + // Mark `use X` lines inside the module body as module + const doBlock = node.children.find((c: any) => c.type === "do_block"); + if (doBlock) { + for (const child of doBlock.children) { + if (child.type === "call" && child.children[0]?.text === "use") { + annotations[child.startPosition.row] = { block: "module" }; + } else if (child.type === "call") { + // Recurse into nested def/defp + walkNode(child, annotations, defpRanges); + } + } + } + return; + } + + if (keyword === "defp") { + defpRanges.push([node.startPosition.row, node.endPosition.row]); + return; + } + + if (keyword === "def") { + annotateDef(node, annotations); + return; + } + + for (const child of node.children) walkNode(child, annotations, defpRanges); +} + +function annotateDef(node: Parser.SyntaxNode, annotations: LineAnnotation[]) { + const outerArgs = node.children.find( + (c: Parser.SyntaxNode) => c.type === "arguments", + ); + if (!outerArgs) return; + + const sigCall = outerArgs.namedChildren?.[0] ?? outerArgs.children[0]; + if (!sigCall || sigCall.type !== "call") return; + + const funcName: string = sigCall.children[0]?.text ?? ""; + if (!funcName) return; + + let block = funcName; + + if (funcName === "handle_event") { + const innerArgs = sigCall.children.find((c: any) => c.type === "arguments"); + const firstArg = innerArgs?.namedChildren?.[0] ?? innerArgs?.children?.[1]; + if (firstArg?.type === "string") { + const qc = firstArg.children.find( + (c: any) => c.type === "quoted_content", + ); + block = qc?.text ?? firstArg.text.replace(/"/g, ""); + } + } else if (funcName === "handle_info") { + const innerArgs = sigCall.children.find((c: any) => c.type === "arguments"); + const firstArg = innerArgs?.namedChildren?.[0] ?? innerArgs?.children?.[1]; + if (firstArg?.type === "atom") { + block = firstArg.text.replace(/^:/, ""); + } + } + + const start: number = node.startPosition.row; + const end: number = node.endPosition.row; + for (let i = start; i <= end; i++) { + annotations[i] = { block }; + } +} + +export function createBlockTransformer(code: string): ShikiTransformer { + const annotations = parseElixirBlocks(code); + + return { + name: "llv-block-annotator", + line(node, line) { + const ann = annotations[line - 1]; + + if (ann?.block) + (node.properties as Record)["data-block"] = ann.block; + + if (ann?.defpGroup) + (node.properties as Record)["data-defp-group"] = ann.defpGroup; + + if (ann?.defpHeader) + (node.properties as Record)["data-defp-header"] = "true"; + + node.children = [ + { type: "element", tagName: "span", children: node.children }, + ]; + }, + }; +} diff --git a/landing-page/src/scripts/local-live-view/store.ts b/landing-page/src/scripts/local-live-view/store.ts new file mode 100644 index 00000000..f0d0dd50 --- /dev/null +++ b/landing-page/src/scripts/local-live-view/store.ts @@ -0,0 +1,23 @@ +import { atom } from "nanostores"; + +export interface LogEntry { + id: number; + event: string; + result: string; +} + +// Animation step: 0=phx-click, 1=handle_event, 2=update assigns, 3=re-render, null=idle +export const $step = atom(null); + +// Current assigns from the LiveView +export const $assigns = atom>({}); + +// Keys that changed in the last update (for flash animation) +export const $flashKeys = atom>(new Set()); + +// Event log entries (newest first, max 6) +export const $log = atom([]); + +export function pushLog(entry: LogEntry) { + $log.set([entry, ...$log.get()].slice(0, 6)); +} diff --git a/landing-page/src/scripts/local-live-view/theme.ts b/landing-page/src/scripts/local-live-view/theme.ts new file mode 100644 index 00000000..36f7b995 --- /dev/null +++ b/landing-page/src/scripts/local-live-view/theme.ts @@ -0,0 +1,83 @@ +import type { ThemeRegistration } from "shiki"; + +export const popcornTheme: ThemeRegistration = { + name: "popcorn-dark", + type: "dark", + colors: { + "editor.background": "var(--color-llv-bg-100)", + "editor.foreground": "var(--color-llv-fg-muted)", + }, + tokenColors: [ + { + scope: [ + "keyword.control", + "keyword.other", + "storage.type", + "storage.modifier", + "keyword.operator.macro", + ], + settings: { foreground: "var(--color-orange-100)" }, + }, + { + scope: [ + "entity.name.type", + "entity.name.class", + "support.class", + "entity.name.namespace", + ], + settings: { foreground: "var(--color-llv-orange-60)", fontStyle: "bold" }, + }, + { + scope: [ + "entity.name.function", + "support.function", + "support.function.kernel", + ], + settings: { foreground: "var(--color-llv-orange-40)" }, + }, + { + scope: [ + "constant.language.symbol", + "string.other.symbol", + "constant.other.symbol", + ], + settings: { foreground: "var(--color-llv-orange-50)" }, + }, + { + scope: [ + "punctuation.section.embedded", + "variable.other.readwrite.elixir", + ], + settings: { foreground: "var(--color-llv-orange-70)", fontStyle: "bold" }, + }, + { + scope: ["string.quoted", "string.interpolated"], + settings: { foreground: "var(--color-llv-green-60)" }, + }, + { + scope: ["constant.numeric"], + settings: { foreground: "var(--color-llv-orange-60)" }, + }, + { + scope: ["keyword.operator"], + settings: { foreground: "var(--color-llv-brown-75)" }, + }, + { + scope: ["entity.name.tag", "meta.tag"], + settings: { foreground: "var(--color-llv-blue-60)" }, + }, + { + scope: ["entity.other.attribute-name"], + settings: { foreground: "var(--color-llv-blue-50)" }, + }, + { + scope: [ + "comment", + "keyword.operator.macro.sigil", + "string.unquoted.heredoc", + "punctuation.definition.string.heredoc", + ], + settings: { foreground: "var(--color-llv-brown-85)" }, + }, + ], +}; diff --git a/landing-page/src/styles/demos/local-forms.css b/landing-page/src/styles/demos/local-forms.css new file mode 100644 index 00000000..203204eb --- /dev/null +++ b/landing-page/src/styles/demos/local-forms.css @@ -0,0 +1,85 @@ +@reference "../global.css"; + +[data-value="users-list"], +[data-value="errors-list"] { + @apply transition-colors duration-300; +} + +[data-value="errors-list"] { + @apply border border-transparent rounded-lg px-2 flex flex-col gap-2; +} + +[data-value="users-list"] { + @apply mb-4 text-[0.8rem]; +} + +[data-value="users-list"] p { + @apply italic; +} + +[data-pop-view].hl-users-list [data-value="users-list"], +[data-pop-view].hl-errors-list [data-value="errors-list"] { + @apply border-orange-100! bg-orange-100/6!; +} + +.local_forms { + @apply flex flex-col gap-4 text-black bg-light-20 border-2 border-brown-header rounded-2xl overflow-hidden shadow-[0_10px_30px_rgba(48,27,5,0.1)]; +} + +.local_forms > div { + @apply px-6; +} + +.local_forms .header { + @apply bg-brown-header text-light-20 text-center py-2; +} + +.local_forms h2 { + @apply font-handjet text-xl mb-2; +} + +.local_forms form { + @apply flex flex-col gap-2; +} + +.local_forms label { + @apply font-semibold text-brown-header text-xs tracking-wider; +} + +.local_forms .input { + @apply w-full border-2 border-brown-header rounded-lg px-4 py-3 bg-light-20 text-black transition-all duration-200; +} + +.local_forms .input:focus { + @apply outline-none border-orange-100 shadow-[0_0_0_3px_rgba(239,124,0,0.1)]; +} + +.local_forms .input::placeholder { + @apply font-normal text-brown-60; +} + +.local_forms .buttons { + @apply flex gap-4; +} + +.local_forms ul { + @apply list-none p-0 m-0 flex flex-col gap-1 text-llv-brown-50; +} + +.ghost-button { + @apply px-5 border-2 border-brown-header bg-transparent text-brown-header rounded-lg cursor-pointer transition-all duration-200 text-sm font-semibold tracking-[0.04em]; + padding-top: 0.6rem; + padding-bottom: 0.6rem; +} + +.ghost-button:hover { + @apply bg-orange-100 border-orange-100 text-light-20; +} + +.ghost-button:disabled { + @apply border-dashed opacity-50 cursor-not-allowed; +} + +.ghost-button:disabled:hover { + @apply bg-transparent border-brown-header text-brown-header; +} diff --git a/landing-page/src/styles/demos/local-thermostat.css b/landing-page/src/styles/demos/local-thermostat.css new file mode 100644 index 00000000..b47f1a0a --- /dev/null +++ b/landing-page/src/styles/demos/local-thermostat.css @@ -0,0 +1,54 @@ +@reference "../global.css"; + +[data-value] { + @apply transition-colors duration-300; +} + +[data-pop-view].hl-temperature [data-value="temperature"] { + @apply text-orange-100!; +} + +[data-pop-view] { + scroll-margin-top: 5.5rem; +} + +.demo-card { + @apply bg-light-20 border-2 border-brown-header rounded-2xl overflow-hidden shadow-[0_10px_30px_rgba(48,27,5,0.1)]; +} + +.clock-band { + @apply bg-brown-header text-light-20 py-3 px-6 text-center font-handjet text-lg tracking-[0.04em]; +} + +.thermostat-wrapper { + @apply flex flex-col items-center gap-5 p-8 w-full; +} + +.thermostat-temp-label { + @apply text-xs uppercase tracking-widest text-llv-brown-50 font-semibold; +} + +.thermostat-temp-value { + @apply font-handjet text-7xl font-bold text-brown-header leading-none transition-colors duration-150 ease-in; +} + +.thermostat-controls { + @apply flex gap-4; +} + +.thermostat-country { + @apply text-sm text-llv-brown-50; +} + +/* ── Ghost button (circular variant) ─────────────────────── */ +.ghost-button { + @apply size-14 p-0 border-2 border-brown-header bg-transparent text-brown-header rounded-full cursor-pointer transition-all duration-150 ease-in text-2xl font-bold flex items-center justify-center font-handjet; +} + +.ghost-button:hover { + @apply bg-orange-100 border-orange-100 text-light-20 scale-[1.08]; +} + +.ghost-button:active { + @apply scale-[0.93]; +} diff --git a/landing-page/src/styles/global.css b/landing-page/src/styles/global.css index b2d03d7f..fb2e4116 100644 --- a/landing-page/src/styles/global.css +++ b/landing-page/src/styles/global.css @@ -16,6 +16,34 @@ --color-brown-60: #696057; --color-brown-gray: #696057; --color-black: #301b05; + --color-llv-bg-100: #1a0f05; + --color-llv-bg-110: #120a02; + --color-llv-brown-50: #7a5535; + --color-llv-brown-55: #5a4535; + --color-llv-brown-75: #5a4030; + --color-llv-brown-85: #4a3020; + --color-llv-brown-95: #3a2810; + --color-llv-gutter: #2e1e0a; + --color-llv-fg-muted: #d4c4b0; + --color-llv-orange-40: #ffe599; + --color-llv-orange-50: #ffc285; + --color-llv-orange-60: #ffa94d; + --color-llv-orange-70: #ef9f40; + --color-llv-green-60: #a8d8a8; + --color-llv-blue-50: #a0c4d4; + --color-llv-blue-60: #80b8d4; --font-handjet: "Handjet Variable", system-ui; --font-inter: "Inter Variable", sans-serif; + --animate-fade-slide: fadeSlide 0.3s ease; + + @keyframes fadeSlide { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } } From be452eb972541eb0233b1b461388e690e4f10279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marceli=20Miet=C5=82a?= Date: Thu, 21 May 2026 12:34:07 +0200 Subject: [PATCH 7/8] feat: refactor demo pages to use LLV presentation components --- .../src/pages/demos/local-forms.astro | 379 +++++++----------- .../src/pages/demos/local-thermostat.astro | 272 ++++++++----- 2 files changed, 319 insertions(+), 332 deletions(-) diff --git a/landing-page/src/pages/demos/local-forms.astro b/landing-page/src/pages/demos/local-forms.astro index f42b379c..202ee760 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,155 @@ 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
      -      
      -      
      -
      + + +
      +
      + + + +
      + + +
      - - -