From faee6fdb250b74964d6cfed69a140dc1d4481443 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Tue, 5 May 2026 04:19:22 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20port=20el-ui=20vessels=20=E2=80=94?= =?UTF-8?q?=20rename=20crates=E2=86=92vessels,=20add=20El=20source=20+=20m?= =?UTF-8?q?anifests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/Cargo.toml | 25 +- ui/{crates => vessels}/el-aop/Cargo.toml | 0 ui/vessels/el-aop/manifest.el | 22 ++ ui/{crates => vessels}/el-aop/src/aspects.rs | 0 ui/{crates => vessels}/el-aop/src/chain.rs | 0 ui/{crates => vessels}/el-aop/src/lib.rs | 0 ui/vessels/el-aop/src/main.el | 223 +++++++++++ ui/{crates => vessels}/el-aop/src/public.rs | 0 ui/{crates => vessels}/el-aop/src/registry.rs | 0 ui/{crates => vessels}/el-aop/src/tests.rs | 0 ui/{crates => vessels}/el-auth/Cargo.toml | 0 ui/vessels/el-auth/manifest.el | 15 + ui/{crates => vessels}/el-auth/src/context.rs | 0 .../el-auth/src/engram_session.rs | 0 ui/{crates => vessels}/el-auth/src/jwt.rs | 0 ui/{crates => vessels}/el-auth/src/lib.rs | 0 ui/vessels/el-auth/src/main.el | 201 ++++++++++ .../el-auth/src/middleware.rs | 0 ui/{crates => vessels}/el-auth/src/roles.rs | 0 ui/{crates => vessels}/el-auth/src/session.rs | 0 ui/{crates => vessels}/el-auth/src/tests.rs | 0 ui/{crates => vessels}/el-config/Cargo.toml | 0 ui/vessels/el-config/manifest.el | 15 + .../el-config/src/config.rs | 0 ui/{crates => vessels}/el-config/src/env.rs | 0 ui/{crates => vessels}/el-config/src/error.rs | 0 ui/{crates => vessels}/el-config/src/lib.rs | 0 ui/vessels/el-config/src/main.el | 177 +++++++++ .../el-config/src/source.rs | 0 ui/vessels/el-graph/manifest.el | 17 + ui/vessels/el-graph/src/canvas.el | 46 +++ ui/vessels/el-graph/src/edge.el | 44 +++ ui/vessels/el-graph/src/editor.el | 138 +++++++ ui/vessels/el-graph/src/layout.el | 352 ++++++++++++++++++ ui/vessels/el-graph/src/main.el | 34 ++ ui/vessels/el-graph/src/node.el | 77 ++++ ui/vessels/el-graph/src/serializer.el | 80 ++++ ui/vessels/el-graph/src/view.el | 155 ++++++++ ui/vessels/el-html/manifest.el | 17 + ui/vessels/el-html/src/main.el | 222 +++++++++++ ui/{crates => vessels}/el-i18n/Cargo.toml | 0 ui/vessels/el-i18n/manifest.el | 15 + ui/{crates => vessels}/el-i18n/src/bundle.rs | 0 ui/{crates => vessels}/el-i18n/src/format.rs | 0 ui/{crates => vessels}/el-i18n/src/lib.rs | 0 ui/{crates => vessels}/el-i18n/src/locale.rs | 0 ui/vessels/el-i18n/src/main.el | 208 +++++++++++ ui/{crates => vessels}/el-i18n/src/plural.rs | 0 ui/{crates => vessels}/el-i18n/src/t.rs | 0 ui/{crates => vessels}/el-identity/Cargo.toml | 0 ui/vessels/el-identity/manifest.el | 16 + .../el-identity/src/context.rs | 0 .../el-identity/src/engram.rs | 0 .../el-identity/src/error.rs | 0 .../el-identity/src/guard.rs | 0 ui/{crates => vessels}/el-identity/src/lib.rs | 0 ui/vessels/el-identity/src/main.el | 271 ++++++++++++++ .../el-identity/src/nodes.rs | 0 .../el-identity/src/oauth.rs | 0 .../el-identity/src/provider.rs | 0 .../el-identity/src/session.rs | 0 .../el-identity/src/tests.rs | 0 ui/{crates => vessels}/el-layout/Cargo.toml | 0 ui/vessels/el-layout/manifest.el | 21 ++ .../el-layout/src/breakpoint.rs | 0 .../el-layout/src/constraints.rs | 0 ui/{crates => vessels}/el-layout/src/flex.rs | 0 ui/{crates => vessels}/el-layout/src/grid.rs | 0 ui/{crates => vessels}/el-layout/src/lib.rs | 0 ui/vessels/el-layout/src/main.el | 318 ++++++++++++++++ .../el-layout/src/platform.rs | 0 .../el-layout/src/responsive.rs | 0 .../el-layout/src/scroll.rs | 0 ui/{crates => vessels}/el-layout/src/stack.rs | 0 ui/{crates => vessels}/el-platform/Cargo.toml | 0 ui/vessels/el-platform/manifest.el | 23 ++ .../el-platform/src/backends/android.rs | 0 .../el-platform/src/backends/ios.rs | 0 .../el-platform/src/backends/linux.rs | 0 .../el-platform/src/backends/macos.rs | 0 .../el-platform/src/backends/mod.rs | 0 .../el-platform/src/backends/server.rs | 0 .../el-platform/src/backends/tests.rs | 0 .../el-platform/src/backends/web.rs | 0 .../el-platform/src/backends/windows.rs | 0 .../el-platform/src/config.rs | 0 ui/{crates => vessels}/el-platform/src/lib.rs | 0 ui/vessels/el-platform/src/main.el | 152 ++++++++ .../el-platform/src/node.rs | 0 ui/{crates => vessels}/el-publish/Cargo.toml | 0 ui/vessels/el-publish/manifest.el | 25 ++ .../el-publish/src/apple.rs | 0 ui/{crates => vessels}/el-publish/src/cert.rs | 0 .../el-publish/src/config.rs | 0 .../el-publish/src/google.rs | 0 ui/{crates => vessels}/el-publish/src/lib.rs | 0 ui/vessels/el-publish/src/main.el | 243 ++++++++++++ .../el-publish/src/metadata.rs | 0 .../el-publish/src/rollout.rs | 0 .../el-publish/src/screenshot.rs | 0 .../el-publish/src/tests.rs | 0 ui/{crates => vessels}/el-secrets/Cargo.toml | 0 ui/vessels/el-secrets/manifest.el | 20 + .../el-secrets/src/error.rs | 0 ui/{crates => vessels}/el-secrets/src/lib.rs | 0 ui/vessels/el-secrets/src/main.el | 195 ++++++++++ .../el-secrets/src/resolver.rs | 0 .../el-secrets/src/secret.rs | 0 .../el-secrets/src/source.rs | 0 ui/{crates => vessels}/el-services/Cargo.toml | 0 ui/vessels/el-services/manifest.el | 20 + .../el-services/src/binding/direct.rs | 0 .../el-services/src/binding/grpc.rs | 0 .../el-services/src/binding/mod.rs | 0 .../el-services/src/binding/rest.rs | 0 .../el-services/src/binding/websocket.rs | 0 .../el-services/src/config.rs | 0 ui/{crates => vessels}/el-services/src/lib.rs | 0 ui/vessels/el-services/src/main.el | 197 ++++++++++ .../el-services/src/proxy.rs | 0 .../el-services/src/registry.rs | 0 .../el-services/src/tests.rs | 0 ui/{crates => vessels}/el-style/Cargo.toml | 0 ui/vessels/el-style/manifest.el | 21 ++ ui/{crates => vessels}/el-style/src/color.rs | 0 ui/{crates => vessels}/el-style/src/lib.rs | 0 ui/vessels/el-style/src/main.el | 287 ++++++++++++++ .../el-style/src/modifier.rs | 0 ui/{crates => vessels}/el-style/src/radius.rs | 0 ui/{crates => vessels}/el-style/src/shadow.rs | 0 .../el-style/src/spacing.rs | 0 .../el-style/src/stylesheet.rs | 0 ui/{crates => vessels}/el-style/src/theme.rs | 0 .../el-style/src/typography.rs | 0 .../el-ui-compiler/Cargo.toml | 0 ui/vessels/el-ui-compiler/manifest.el | 28 ++ .../el-ui-compiler/src/ast.rs | 0 .../el-ui-compiler/src/codegen.rs | 0 .../el-ui-compiler/src/error.rs | 0 .../el-ui-compiler/src/lexer.rs | 0 .../el-ui-compiler/src/lib.rs | 0 ui/vessels/el-ui-compiler/src/main.el | 142 +++++++ .../el-ui-compiler/src/main.rs | 0 .../el-ui-compiler/src/parser.rs | 0 .../el-ui-compiler/src/tests.rs | 0 145 files changed, 4050 insertions(+), 12 deletions(-) rename ui/{crates => vessels}/el-aop/Cargo.toml (100%) create mode 100644 ui/vessels/el-aop/manifest.el rename ui/{crates => vessels}/el-aop/src/aspects.rs (100%) rename ui/{crates => vessels}/el-aop/src/chain.rs (100%) rename ui/{crates => vessels}/el-aop/src/lib.rs (100%) create mode 100644 ui/vessels/el-aop/src/main.el rename ui/{crates => vessels}/el-aop/src/public.rs (100%) rename ui/{crates => vessels}/el-aop/src/registry.rs (100%) rename ui/{crates => vessels}/el-aop/src/tests.rs (100%) rename ui/{crates => vessels}/el-auth/Cargo.toml (100%) create mode 100644 ui/vessels/el-auth/manifest.el rename ui/{crates => vessels}/el-auth/src/context.rs (100%) rename ui/{crates => vessels}/el-auth/src/engram_session.rs (100%) rename ui/{crates => vessels}/el-auth/src/jwt.rs (100%) rename ui/{crates => vessels}/el-auth/src/lib.rs (100%) create mode 100644 ui/vessels/el-auth/src/main.el rename ui/{crates => vessels}/el-auth/src/middleware.rs (100%) rename ui/{crates => vessels}/el-auth/src/roles.rs (100%) rename ui/{crates => vessels}/el-auth/src/session.rs (100%) rename ui/{crates => vessels}/el-auth/src/tests.rs (100%) rename ui/{crates => vessels}/el-config/Cargo.toml (100%) create mode 100644 ui/vessels/el-config/manifest.el rename ui/{crates => vessels}/el-config/src/config.rs (100%) rename ui/{crates => vessels}/el-config/src/env.rs (100%) rename ui/{crates => vessels}/el-config/src/error.rs (100%) rename ui/{crates => vessels}/el-config/src/lib.rs (100%) create mode 100644 ui/vessels/el-config/src/main.el rename ui/{crates => vessels}/el-config/src/source.rs (100%) create mode 100644 ui/vessels/el-graph/manifest.el create mode 100644 ui/vessels/el-graph/src/canvas.el create mode 100644 ui/vessels/el-graph/src/edge.el create mode 100644 ui/vessels/el-graph/src/editor.el create mode 100644 ui/vessels/el-graph/src/layout.el create mode 100644 ui/vessels/el-graph/src/main.el create mode 100644 ui/vessels/el-graph/src/node.el create mode 100644 ui/vessels/el-graph/src/serializer.el create mode 100644 ui/vessels/el-graph/src/view.el create mode 100644 ui/vessels/el-html/manifest.el create mode 100644 ui/vessels/el-html/src/main.el rename ui/{crates => vessels}/el-i18n/Cargo.toml (100%) create mode 100644 ui/vessels/el-i18n/manifest.el rename ui/{crates => vessels}/el-i18n/src/bundle.rs (100%) rename ui/{crates => vessels}/el-i18n/src/format.rs (100%) rename ui/{crates => vessels}/el-i18n/src/lib.rs (100%) rename ui/{crates => vessels}/el-i18n/src/locale.rs (100%) create mode 100644 ui/vessels/el-i18n/src/main.el rename ui/{crates => vessels}/el-i18n/src/plural.rs (100%) rename ui/{crates => vessels}/el-i18n/src/t.rs (100%) rename ui/{crates => vessels}/el-identity/Cargo.toml (100%) create mode 100644 ui/vessels/el-identity/manifest.el rename ui/{crates => vessels}/el-identity/src/context.rs (100%) rename ui/{crates => vessels}/el-identity/src/engram.rs (100%) rename ui/{crates => vessels}/el-identity/src/error.rs (100%) rename ui/{crates => vessels}/el-identity/src/guard.rs (100%) rename ui/{crates => vessels}/el-identity/src/lib.rs (100%) create mode 100644 ui/vessels/el-identity/src/main.el rename ui/{crates => vessels}/el-identity/src/nodes.rs (100%) rename ui/{crates => vessels}/el-identity/src/oauth.rs (100%) rename ui/{crates => vessels}/el-identity/src/provider.rs (100%) rename ui/{crates => vessels}/el-identity/src/session.rs (100%) rename ui/{crates => vessels}/el-identity/src/tests.rs (100%) rename ui/{crates => vessels}/el-layout/Cargo.toml (100%) create mode 100644 ui/vessels/el-layout/manifest.el rename ui/{crates => vessels}/el-layout/src/breakpoint.rs (100%) rename ui/{crates => vessels}/el-layout/src/constraints.rs (100%) rename ui/{crates => vessels}/el-layout/src/flex.rs (100%) rename ui/{crates => vessels}/el-layout/src/grid.rs (100%) rename ui/{crates => vessels}/el-layout/src/lib.rs (100%) create mode 100644 ui/vessels/el-layout/src/main.el rename ui/{crates => vessels}/el-layout/src/platform.rs (100%) rename ui/{crates => vessels}/el-layout/src/responsive.rs (100%) rename ui/{crates => vessels}/el-layout/src/scroll.rs (100%) rename ui/{crates => vessels}/el-layout/src/stack.rs (100%) rename ui/{crates => vessels}/el-platform/Cargo.toml (100%) create mode 100644 ui/vessels/el-platform/manifest.el rename ui/{crates => vessels}/el-platform/src/backends/android.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/ios.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/linux.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/macos.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/mod.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/server.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/tests.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/web.rs (100%) rename ui/{crates => vessels}/el-platform/src/backends/windows.rs (100%) rename ui/{crates => vessels}/el-platform/src/config.rs (100%) rename ui/{crates => vessels}/el-platform/src/lib.rs (100%) create mode 100644 ui/vessels/el-platform/src/main.el rename ui/{crates => vessels}/el-platform/src/node.rs (100%) rename ui/{crates => vessels}/el-publish/Cargo.toml (100%) create mode 100644 ui/vessels/el-publish/manifest.el rename ui/{crates => vessels}/el-publish/src/apple.rs (100%) rename ui/{crates => vessels}/el-publish/src/cert.rs (100%) rename ui/{crates => vessels}/el-publish/src/config.rs (100%) rename ui/{crates => vessels}/el-publish/src/google.rs (100%) rename ui/{crates => vessels}/el-publish/src/lib.rs (100%) create mode 100644 ui/vessels/el-publish/src/main.el rename ui/{crates => vessels}/el-publish/src/metadata.rs (100%) rename ui/{crates => vessels}/el-publish/src/rollout.rs (100%) rename ui/{crates => vessels}/el-publish/src/screenshot.rs (100%) rename ui/{crates => vessels}/el-publish/src/tests.rs (100%) rename ui/{crates => vessels}/el-secrets/Cargo.toml (100%) create mode 100644 ui/vessels/el-secrets/manifest.el rename ui/{crates => vessels}/el-secrets/src/error.rs (100%) rename ui/{crates => vessels}/el-secrets/src/lib.rs (100%) create mode 100644 ui/vessels/el-secrets/src/main.el rename ui/{crates => vessels}/el-secrets/src/resolver.rs (100%) rename ui/{crates => vessels}/el-secrets/src/secret.rs (100%) rename ui/{crates => vessels}/el-secrets/src/source.rs (100%) rename ui/{crates => vessels}/el-services/Cargo.toml (100%) create mode 100644 ui/vessels/el-services/manifest.el rename ui/{crates => vessels}/el-services/src/binding/direct.rs (100%) rename ui/{crates => vessels}/el-services/src/binding/grpc.rs (100%) rename ui/{crates => vessels}/el-services/src/binding/mod.rs (100%) rename ui/{crates => vessels}/el-services/src/binding/rest.rs (100%) rename ui/{crates => vessels}/el-services/src/binding/websocket.rs (100%) rename ui/{crates => vessels}/el-services/src/config.rs (100%) rename ui/{crates => vessels}/el-services/src/lib.rs (100%) create mode 100644 ui/vessels/el-services/src/main.el rename ui/{crates => vessels}/el-services/src/proxy.rs (100%) rename ui/{crates => vessels}/el-services/src/registry.rs (100%) rename ui/{crates => vessels}/el-services/src/tests.rs (100%) rename ui/{crates => vessels}/el-style/Cargo.toml (100%) create mode 100644 ui/vessels/el-style/manifest.el rename ui/{crates => vessels}/el-style/src/color.rs (100%) rename ui/{crates => vessels}/el-style/src/lib.rs (100%) create mode 100644 ui/vessels/el-style/src/main.el rename ui/{crates => vessels}/el-style/src/modifier.rs (100%) rename ui/{crates => vessels}/el-style/src/radius.rs (100%) rename ui/{crates => vessels}/el-style/src/shadow.rs (100%) rename ui/{crates => vessels}/el-style/src/spacing.rs (100%) rename ui/{crates => vessels}/el-style/src/stylesheet.rs (100%) rename ui/{crates => vessels}/el-style/src/theme.rs (100%) rename ui/{crates => vessels}/el-style/src/typography.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/Cargo.toml (100%) create mode 100644 ui/vessels/el-ui-compiler/manifest.el rename ui/{crates => vessels}/el-ui-compiler/src/ast.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/src/codegen.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/src/error.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/src/lexer.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/src/lib.rs (100%) create mode 100644 ui/vessels/el-ui-compiler/src/main.el rename ui/{crates => vessels}/el-ui-compiler/src/main.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/src/parser.rs (100%) rename ui/{crates => vessels}/el-ui-compiler/src/tests.rs (100%) diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 6241e2a..fcd21d5 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -1,17 +1,18 @@ [workspace] members = [ - "crates/el-ui-compiler", - "crates/el-platform", - "crates/el-services", - "crates/el-aop", - "crates/el-auth", - "crates/el-publish", - "crates/el-identity", - "crates/el-style", - "crates/el-layout", - "crates/el-i18n", - "crates/el-config", - "crates/el-secrets", + "vessels/el-ui-compiler", + "vessels/el-platform", + "vessels/el-services", + "vessels/el-aop", + "vessels/el-auth", + "vessels/el-publish", + "vessels/el-identity", + "vessels/el-style", + "vessels/el-layout", + "vessels/el-i18n", + "vessels/el-config", + "vessels/el-secrets", + "vessels/el-html", "examples/profile-card", ] resolver = "2" diff --git a/ui/crates/el-aop/Cargo.toml b/ui/vessels/el-aop/Cargo.toml similarity index 100% rename from ui/crates/el-aop/Cargo.toml rename to ui/vessels/el-aop/Cargo.toml diff --git a/ui/vessels/el-aop/manifest.el b/ui/vessels/el-aop/manifest.el new file mode 100644 index 0000000..84ed41f --- /dev/null +++ b/ui/vessels/el-aop/manifest.el @@ -0,0 +1,22 @@ +// el-aop — Aspect-Oriented Programming for el-ui. +// +// Cross-cutting concerns as first-class language features. Decorators applied +// to components and methods. @authenticate is the default; @public is the +// explicit opt-out. + +vessel "el-aop" { + version "0.1.0" + description "Decorators: @authenticate, @authorize, @cache, @rate_limit, ..." + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-auth "0.1" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-aop/src/aspects.rs b/ui/vessels/el-aop/src/aspects.rs similarity index 100% rename from ui/crates/el-aop/src/aspects.rs rename to ui/vessels/el-aop/src/aspects.rs diff --git a/ui/crates/el-aop/src/chain.rs b/ui/vessels/el-aop/src/chain.rs similarity index 100% rename from ui/crates/el-aop/src/chain.rs rename to ui/vessels/el-aop/src/chain.rs diff --git a/ui/crates/el-aop/src/lib.rs b/ui/vessels/el-aop/src/lib.rs similarity index 100% rename from ui/crates/el-aop/src/lib.rs rename to ui/vessels/el-aop/src/lib.rs diff --git a/ui/vessels/el-aop/src/main.el b/ui/vessels/el-aop/src/main.el new file mode 100644 index 0000000..10cef7a --- /dev/null +++ b/ui/vessels/el-aop/src/main.el @@ -0,0 +1,223 @@ +// el-aop — Aspect-Oriented Programming for el-ui. +// +// Each aspect has three advice points: before, after, around. The aspect +// chain is composed at compile time by the el-ui-compiler from decorators. +// +// The El runtime model uses tagged JSON for InvocationContext so an aspect +// chain composes purely by passing the context map through each advice fn. +// +// Built-in aspects: +// @authenticate — defaults on every component (security-by-default) +// @public — opt-out marker +// @authorize — role/permission gate +// @cache — TTL-keyed memoization +// @rate_limit — per-principal token bucket +// @retry — retry on error with backoff +// @log — structured log around invocation +// @trace — emit OpenTelemetry-shaped spans +// @validate — JSON schema check on args + +// ── Errors ─────────────────────────────────────────────────────────────────── + +let AOP_ERR_UNAUTHENTICATED: String = "aop.unauthenticated" +let AOP_ERR_FORBIDDEN: String = "aop.forbidden" +let AOP_ERR_RATE_LIMITED: String = "aop.rate_limited" +let AOP_ERR_VALIDATION: String = "aop.validation_failed" +let AOP_ERR_RETRIES_EXHAUSTED: String = "aop.retries_exhausted" + +// ── Invocation context ─────────────────────────────────────────────────────── +// +// Stored as JSON: { target, method, args:{}, metadata:{} } +// All advice mutates by returning a new ctx (functional style). + +fn ctx_new(target: String, method: String) -> String { + "{\"target\":\"" + target + "\",\"method\":\"" + method + + "\",\"args\":{},\"metadata\":{}}" +} + +fn ctx_with_arg(ctx: String, key: String, value: String) -> String { + json_set_path(ctx, "args." + key, "\"" + value + "\"") +} + +fn ctx_with_meta(ctx: String, key: String, value: String) -> String { + json_set_path(ctx, "metadata." + key, "\"" + value + "\"") +} + +fn ctx_get_meta(ctx: String, key: String) -> String { + json_get_path(ctx, "metadata." + key) +} + +// ── Aspect dispatch table ──────────────────────────────────────────────────── +// +// Each aspect is a triple of fn names: (before, around, after). The registry +// is a map from aspect name -> { before, around, after } JSON. + +fn registry_new() -> String { + "{}" +} + +fn registry_register(reg: String, name: String, before_fn: String, around_fn: String, after_fn: String) -> String { + let entry: String = "{\"before\":\"" + before_fn + "\",\"around\":\"" + around_fn + + "\",\"after\":\"" + after_fn + "\"}" + json_set(reg, name, entry) +} + +// ── @authenticate — applied by default ─────────────────────────────────────── + +fn aspect_authenticate_before(ctx: String) -> String { + let token: String = ctx_get_meta(ctx, "authorization") + if str_eq(token, "") { + return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED) + } + let secret: String = env("JWT_SECRET") + let auth_ctx: String = auth_middleware(token, secret) + let user_id: String = json_get(auth_ctx, "user_id") + if str_eq(user_id, "") { + return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED) + } + ctx_with_meta(ctx, "user_id", user_id) +} + +// ── @public — explicit opt-out marker ──────────────────────────────────────── + +fn aspect_public_before(ctx: String) -> String { + ctx_with_meta(ctx, "public", "true") +} + +// ── @authorize(role) ───────────────────────────────────────────────────────── + +fn aspect_authorize_before(ctx: String, required_role: String) -> String { + let user_id: String = ctx_get_meta(ctx, "user_id") + if str_eq(user_id, "") { + return ctx_with_meta(ctx, "error", AOP_ERR_UNAUTHENTICATED) + } + let roles_json: String = engram_edge_traverse(user_id, "has_role") + if !str_contains(roles_json, "\"" + required_role + "\"") { + return ctx_with_meta(ctx, "error", AOP_ERR_FORBIDDEN) + } + ctx +} + +// ── @cache(ttl_seconds) ────────────────────────────────────────────────────── + +fn aspect_cache_around(ctx: String, ttl: Int, proceed: String) -> String { + let key: String = ctx_get_meta(ctx, "cache_key") + if str_eq(key, "") { + let target: String = json_get(ctx, "target") + let method: String = json_get(ctx, "method") + let args: String = json_get(ctx, "args") + let key = sha256_hex(target + ":" + method + ":" + args) + } + let cached: String = cache_get(key) + if !str_eq(cached, "") { + return ctx_with_meta(ctx, "result", cached) + } + // Caller invokes proceed(ctx) externally; we record the key for `after` to use. + ctx_with_meta(ctx, "cache_key", key) +} + +fn aspect_cache_after(ctx: String, result: String, ttl: Int) -> String { + let key: String = ctx_get_meta(ctx, "cache_key") + if !str_eq(key, "") { cache_put(key, result, ttl) } + result +} + +// ── @rate_limit(requests, per_seconds) ─────────────────────────────────────── + +fn aspect_rate_limit_before(ctx: String, requests: Int, per_seconds: Int) -> String { + let principal: String = ctx_get_meta(ctx, "user_id") + if str_eq(principal, "") { let principal = ctx_get_meta(ctx, "ip") } + let bucket_key: String = "rl:" + json_get(ctx, "target") + ":" + principal + let allowed: Bool = rate_bucket_take(bucket_key, requests, per_seconds) + if !allowed { return ctx_with_meta(ctx, "error", AOP_ERR_RATE_LIMITED) } + ctx +} + +// ── @retry(attempts, backoff_ms) ──────────────────────────────────────────── +// +// retry is necessarily an `around` aspect — it must own the loop. + +fn aspect_retry_around(ctx: String, attempts: Int, backoff_ms: Int, proceed_fn_name: String) -> String { + let i: Int = 0 + let result: String = "" + while i < attempts { + let result = call_dynamic(proceed_fn_name, ctx) + let err: String = json_get(result, "error") + if str_eq(err, "") { return result } + sleep_ms(backoff_ms * (i + 1)) + let i = i + 1 + } + ctx_with_meta(ctx, "error", AOP_ERR_RETRIES_EXHAUSTED) +} + +// ── @log / @trace ──────────────────────────────────────────────────────────── + +fn aspect_log_before(ctx: String) -> String { + println("[aop] -> " + json_get(ctx, "target") + "." + json_get(ctx, "method")) + ctx +} + +fn aspect_log_after(ctx: String, result: String) -> String { + println("[aop] <- " + json_get(ctx, "target") + "." + json_get(ctx, "method")) + result +} + +fn aspect_trace_before(ctx: String) -> String { + let span_id: String = uuid_v4() + ctx_with_meta(ctx, "span_id", span_id) +} + +// ── @validate(schema) ──────────────────────────────────────────────────────── + +fn aspect_validate_before(ctx: String, schema_json: String) -> String { + let args: String = json_get(ctx, "args") + let valid: Bool = json_schema_check(args, schema_json) + if !valid { return ctx_with_meta(ctx, "error", AOP_ERR_VALIDATION) } + ctx +} + +// ── Aspect chain composition ───────────────────────────────────────────────── +// +// The compiler emits a call sequence like: +// ctx = ctx_new(...) +// ctx = aspect_authenticate_before(ctx) +// ctx = aspect_log_before(ctx) +// result = proceed(ctx) +// result = aspect_log_after(ctx, result) +// At runtime an explicit `chain_run` exists for dynamic composition. + +fn chain_run(ctx: String, before_fns: String, around_fn: String, after_fns: String, proceed_fn: String) -> String { + let cur_ctx: String = ctx + // before chain + let i: Int = 0 + let befs: String = before_fns + while !str_eq(befs, "") { + let comma: Int = str_index_of(befs, ",") + let fn_name: String = befs + if comma > 0 { let fn_name = str_slice(befs, 0, comma) } + let cur_ctx = call_dynamic(fn_name, cur_ctx) + let err: String = ctx_get_meta(cur_ctx, "error") + if !str_eq(err, "") { return cur_ctx } + if comma > 0 { let befs = str_slice(befs, comma + 1, str_len(befs)) } + if comma < 0 { let befs = "" } + } + // around / proceed + let result: String = call_dynamic(proceed_fn, cur_ctx) + // after chain (right-to-left composition; simplified left-to-right here) + let afts: String = after_fns + while !str_eq(afts, "") { + let comma: Int = str_index_of(afts, ",") + let fn_name: String = afts + if comma > 0 { let fn_name = str_slice(afts, 0, comma) } + let result = call_dynamic2(fn_name, cur_ctx, result) + if comma > 0 { let afts = str_slice(afts, comma + 1, str_len(afts)) } + if comma < 0 { let afts = "" } + } + result +} + +// ── Entry — smoke test ─────────────────────────────────────────────────────── + +let ctx: String = ctx_new("ProfilePage", "render") +let ctx = ctx_with_arg(ctx, "user_id", "u-001") +println("[el-aop] ctx = " + ctx) diff --git a/ui/crates/el-aop/src/public.rs b/ui/vessels/el-aop/src/public.rs similarity index 100% rename from ui/crates/el-aop/src/public.rs rename to ui/vessels/el-aop/src/public.rs diff --git a/ui/crates/el-aop/src/registry.rs b/ui/vessels/el-aop/src/registry.rs similarity index 100% rename from ui/crates/el-aop/src/registry.rs rename to ui/vessels/el-aop/src/registry.rs diff --git a/ui/crates/el-aop/src/tests.rs b/ui/vessels/el-aop/src/tests.rs similarity index 100% rename from ui/crates/el-aop/src/tests.rs rename to ui/vessels/el-aop/src/tests.rs diff --git a/ui/crates/el-auth/Cargo.toml b/ui/vessels/el-auth/Cargo.toml similarity index 100% rename from ui/crates/el-auth/Cargo.toml rename to ui/vessels/el-auth/Cargo.toml diff --git a/ui/vessels/el-auth/manifest.el b/ui/vessels/el-auth/manifest.el new file mode 100644 index 0000000..2d80f97 --- /dev/null +++ b/ui/vessels/el-auth/manifest.el @@ -0,0 +1,15 @@ +vessel "el-auth" { + version "0.1.0" + description "Authentication and authorization: JWT, sessions, roles, permissions" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-auth/src/context.rs b/ui/vessels/el-auth/src/context.rs similarity index 100% rename from ui/crates/el-auth/src/context.rs rename to ui/vessels/el-auth/src/context.rs diff --git a/ui/crates/el-auth/src/engram_session.rs b/ui/vessels/el-auth/src/engram_session.rs similarity index 100% rename from ui/crates/el-auth/src/engram_session.rs rename to ui/vessels/el-auth/src/engram_session.rs diff --git a/ui/crates/el-auth/src/jwt.rs b/ui/vessels/el-auth/src/jwt.rs similarity index 100% rename from ui/crates/el-auth/src/jwt.rs rename to ui/vessels/el-auth/src/jwt.rs diff --git a/ui/crates/el-auth/src/lib.rs b/ui/vessels/el-auth/src/lib.rs similarity index 100% rename from ui/crates/el-auth/src/lib.rs rename to ui/vessels/el-auth/src/lib.rs diff --git a/ui/vessels/el-auth/src/main.el b/ui/vessels/el-auth/src/main.el new file mode 100644 index 0000000..bea71f4 --- /dev/null +++ b/ui/vessels/el-auth/src/main.el @@ -0,0 +1,201 @@ +// el-auth — Built-in authentication and authorization for el-ui. +// +// Engram-aware sessions: server-side invalidation works even with stateless +// JWTs because every session is also a graph node. +// +// Provider trait surface: +// verify(token) -> AuthContext +// issue(user, roles) -> token string +// revoke(token) -> Bool + +// ── Errors ──────────────────────────────────────────────────────────────────── + +let ERR_INVALID_CREDS: String = "auth.invalid_credentials" +let ERR_TOKEN_EXPIRED: String = "auth.token_expired" +let ERR_TOKEN_INVALID: String = "auth.token_invalid" +let ERR_SESSION_NOT_FOUND: String = "auth.session_not_found" +let ERR_FORBIDDEN: String = "auth.forbidden" +let ERR_CONFIG: String = "auth.config" + +// ── AuthContext + AuthUser ──────────────────────────────────────────────────── + +type AuthUser { + id: String + email: String + display_name: String +} + +type AuthContext { + user_id: String + email: String + roles: String // JSON array of role names + permissions: String // JSON array of permission strings + session_id: String + issued_at: String + expires_at: String +} + +fn auth_context_empty() -> AuthContext { + { "user_id": "", "email": "", "roles": "[]", "permissions": "[]", + "session_id": "", "issued_at": "", "expires_at": "" } +} + +fn auth_context_has_permission(ctx: AuthContext, perm: String) -> Bool { + str_contains(ctx.permissions, "\"" + perm + "\"") +} + +fn auth_context_has_role(ctx: AuthContext, role: String) -> Bool { + str_contains(ctx.roles, "\"" + role + "\"") +} + +// ── Roles + permissions ─────────────────────────────────────────────────────── + +type Permission { + resource: String + action: String +} + +fn permission_new(resource: String, action: String) -> Permission { + { "resource": resource, "action": action } +} + +fn permission_string(p: Permission) -> String { + p.resource + ":" + p.action +} + +// Role registry — maps role name -> JSON array of permission strings. +fn role_registry_grant(registry_path: String, role: String, perm: String) -> Bool { + let raw: String = fs_read(registry_path) + let updated: String = json_array_append(raw, role, "\"" + perm + "\"") + fs_write(registry_path, updated) +} + +// ── JWT (HS256) ────────────────────────────────────────────────────────────── + +type JwtClaims { + sub: String // user id + iss: String // issuer + aud: String // audience + iat: Int // issued at (unix seconds) + exp: Int // expires at (unix seconds) + jti: String // unique token id +} + +fn jwt_claims_new(user_id: String, issuer: String, audience: String, ttl_seconds: Int) -> JwtClaims { + let now: Int = time_now_unix() + { "sub": user_id, "iss": issuer, "aud": audience, + "iat": now, "exp": now + ttl_seconds, "jti": uuid_v4() } +} + +fn jwt_encode(claims: JwtClaims, secret: String) -> String { + let header_b64: String = base64url_no_pad("{\"alg\":\"HS256\",\"typ\":\"JWT\"}") + let payload_json: String = json_encode(claims) + let payload_b64: String = base64url_no_pad(payload_json) + let signing_input: String = header_b64 + "." + payload_b64 + let sig: String = base64url_no_pad(hmac_sha256(secret, signing_input)) + signing_input + "." + sig +} + +fn jwt_decode(token: String, secret: String) -> AuthContext { + let parts: String = token // [header].[payload].[sig] + let dot1: Int = str_index_of(parts, ".") + if dot1 < 0 { return auth_context_empty() } + let rest: String = str_slice(parts, dot1 + 1, str_len(parts)) + let dot2: Int = str_index_of(rest, ".") + if dot2 < 0 { return auth_context_empty() } + let header_b64: String = str_slice(parts, 0, dot1) + let payload_b64: String = str_slice(rest, 0, dot2) + let sig_b64: String = str_slice(rest, dot2 + 1, str_len(rest)) + + let signing_input: String = header_b64 + "." + payload_b64 + let expected_sig: String = base64url_no_pad(hmac_sha256(secret, signing_input)) + if !str_eq(expected_sig, sig_b64) { return auth_context_empty() } + + let payload_json: String = base64url_decode(payload_b64) + let now: Int = time_now_unix() + let exp: Int = str_to_int(json_get(payload_json, "exp")) + if exp < now { return auth_context_empty() } + + { "user_id": json_get(payload_json, "sub"), + "email": "", + "roles": "[]", + "permissions": "[]", + "session_id": json_get(payload_json, "jti"), + "issued_at": json_get(payload_json, "iat"), + "expires_at": json_get(payload_json, "exp") } +} + +// ── Engram-backed session store ────────────────────────────────────────────── +// +// Sessions are nodes of type "Session" connected to User via has_session. +// Revocation = node delete. Verification = node lookup + expiry check. + +fn session_store_create(user_id: String, ttl_seconds: Int, ip: String) -> String { + let now: String = time_now_iso() + let exp: String = time_add_seconds(now, ttl_seconds) + let id: String = uuid_v4() + let body: String = "{\"id\":\"" + id + "\",\"user_id\":\"" + user_id + + "\",\"created_at\":\"" + now + "\",\"expires_at\":\"" + exp + + "\",\"ip_address\":\"" + ip + "\"}" + let node_id: String = engram_node_create("Session", body) + engram_edge_create(user_id, node_id, "has_session") + node_id +} + +fn session_store_verify(session_id: String) -> Bool { + let raw: String = engram_node_get(session_id) + if str_eq(raw, "") { return false } + let exp: String = json_get(raw, "expires_at") + !time_after(time_now_iso(), exp) +} + +fn session_store_revoke(session_id: String) -> Bool { + engram_node_delete(session_id) +} + +// ── Middleware ──────────────────────────────────────────────────────────────── +// +// auth_middleware extracts the bearer token, decodes it, and populates the +// AuthContext. Applied automatically by the @authenticate aspect (el-aop). + +fn extract_bearer(authorization_header: String) -> String { + if str_starts_with(authorization_header, "Bearer ") { + return str_slice(authorization_header, 7, str_len(authorization_header)) + } + "" +} + +fn auth_middleware(authorization_header: String, jwt_secret: String) -> AuthContext { + let token: String = extract_bearer(authorization_header) + if str_eq(token, "") { return auth_context_empty() } + jwt_decode(token, jwt_secret) +} + +fn enforce_permission(ctx: AuthContext, required_perm: String) -> Bool { + if str_eq(ctx.user_id, "") { return false } + auth_context_has_permission(ctx, required_perm) +} + +// ── Provider issue/verify ──────────────────────────────────────────────────── + +fn provider_issue(user: AuthUser, jwt_secret: String, issuer: String, audience: String, ttl: Int) -> String { + let claims: JwtClaims = jwt_claims_new(user.id, issuer, audience, ttl) + jwt_encode(claims, jwt_secret) +} + +fn provider_verify(token: String, jwt_secret: String) -> AuthContext { + let ctx: AuthContext = jwt_decode(token, jwt_secret) + if str_eq(ctx.user_id, "") { return ctx } + if !session_store_verify(ctx.session_id) { return auth_context_empty() } + ctx +} + +fn provider_revoke(session_id: String) -> Bool { + session_store_revoke(session_id) +} + +// ── Entry — smoke test ─────────────────────────────────────────────────────── + +let user: AuthUser = { "id": "u-001", "email": "will@neurontechnologies.ai", "display_name": "Will" } +let token: String = provider_issue(user, "test-secret", "el-ui", "el-app", 3600) +println("[el-auth] issued JWT for " + user.email) diff --git a/ui/crates/el-auth/src/middleware.rs b/ui/vessels/el-auth/src/middleware.rs similarity index 100% rename from ui/crates/el-auth/src/middleware.rs rename to ui/vessels/el-auth/src/middleware.rs diff --git a/ui/crates/el-auth/src/roles.rs b/ui/vessels/el-auth/src/roles.rs similarity index 100% rename from ui/crates/el-auth/src/roles.rs rename to ui/vessels/el-auth/src/roles.rs diff --git a/ui/crates/el-auth/src/session.rs b/ui/vessels/el-auth/src/session.rs similarity index 100% rename from ui/crates/el-auth/src/session.rs rename to ui/vessels/el-auth/src/session.rs diff --git a/ui/crates/el-auth/src/tests.rs b/ui/vessels/el-auth/src/tests.rs similarity index 100% rename from ui/crates/el-auth/src/tests.rs rename to ui/vessels/el-auth/src/tests.rs diff --git a/ui/crates/el-config/Cargo.toml b/ui/vessels/el-config/Cargo.toml similarity index 100% rename from ui/crates/el-config/Cargo.toml rename to ui/vessels/el-config/Cargo.toml diff --git a/ui/vessels/el-config/manifest.el b/ui/vessels/el-config/manifest.el new file mode 100644 index 0000000..14d6adf --- /dev/null +++ b/ui/vessels/el-config/manifest.el @@ -0,0 +1,15 @@ +vessel "el-config" { + version "0.1.0" + description "Layered, typed configuration: env, dotenv, manifest, defaults" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-config/src/config.rs b/ui/vessels/el-config/src/config.rs similarity index 100% rename from ui/crates/el-config/src/config.rs rename to ui/vessels/el-config/src/config.rs diff --git a/ui/crates/el-config/src/env.rs b/ui/vessels/el-config/src/env.rs similarity index 100% rename from ui/crates/el-config/src/env.rs rename to ui/vessels/el-config/src/env.rs diff --git a/ui/crates/el-config/src/error.rs b/ui/vessels/el-config/src/error.rs similarity index 100% rename from ui/crates/el-config/src/error.rs rename to ui/vessels/el-config/src/error.rs diff --git a/ui/crates/el-config/src/lib.rs b/ui/vessels/el-config/src/lib.rs similarity index 100% rename from ui/crates/el-config/src/lib.rs rename to ui/vessels/el-config/src/lib.rs diff --git a/ui/vessels/el-config/src/main.el b/ui/vessels/el-config/src/main.el new file mode 100644 index 0000000..476a874 --- /dev/null +++ b/ui/vessels/el-config/src/main.el @@ -0,0 +1,177 @@ +// el-config — Layered, typed configuration for el-ui. +// +// Each layer is a key->value map. `Config::get(key)` walks layers from highest +// to lowest priority and returns the first hit. Values are parsed into the +// caller-requested type via `parse_`. + +// ── Environment ────────────────────────────────────────────────────────────── + +let ENV_DEVELOPMENT: String = "development" +let ENV_STAGING: String = "staging" +let ENV_PRODUCTION: String = "production" +let ENV_TEST: String = "test" + +fn env_current() -> String { + let raw: String = env("EL_ENV") + if str_eq(raw, "") { return ENV_DEVELOPMENT } + raw +} + +fn env_is_production(name: String) -> Bool { + str_eq(name, "production") +} + +// ── Errors ─────────────────────────────────────────────────────────────────── + +let CFG_ERR_MISSING: String = "config.missing" +let CFG_ERR_PARSE: String = "config.parse_error" +let CFG_ERR_TYPE: String = "config.type_error" + +// ── Sources ────────────────────────────────────────────────────────────────── + +let SRC_ENV: String = "env" +let SRC_DOTENV: String = "dotenv" +let SRC_MANIFEST: String = "manifest" +let SRC_DEFAULTS: String = "defaults" +let SRC_MAP: String = "map" + +type ConfigSource { + kind: String + priority: Int // higher = wins + map_json: String // JSON: key -> string value +} + +fn source_env_vars(prefix: String) -> ConfigSource { + // Snapshot env at construction (the runtime exposes env_keys()). + let map: String = "{}" + let keys: String = env_keys_with_prefix(prefix) + let n: Int = json_array_len(keys) + let i: Int = 0 + while i < n { + let raw_key: String = json_array_get(keys, i) + let value: String = env(raw_key) + let logical_key: String = str_to_lower(str_replace(str_slice(raw_key, str_len(prefix), str_len(raw_key)), "_", ".")) + let map = json_set(map, logical_key, "\"" + value + "\"") + let i = i + 1 + } + { "kind": "env", "priority": 100, "map_json": map } +} + +fn source_dotenv(path: String) -> ConfigSource { + let raw: String = "" + if fs_exists(path) { let raw = fs_read(path) } + let map: String = dotenv_parse(raw) + { "kind": "dotenv", "priority": 80, "map_json": map } +} + +fn source_manifest(manifest_path: String, current_env: String) -> ConfigSource { + let raw: String = fs_read(manifest_path) + let base: String = manifest_section(raw, "config") + let env_key: String = "env." + current_env + let env_overrides: String = manifest_section(raw, env_key) + let map: String = json_merge(base, env_overrides) + { "kind": "manifest", "priority": 60, "map_json": map } +} + +fn source_defaults(map_json: String) -> ConfigSource { + { "kind": "defaults", "priority": 0, "map_json": map_json } +} + +// ── Config ─────────────────────────────────────────────────────────────────── + +type Config { + environment: String // development | staging | production | test + sources_json: String // JSON array of ConfigSource +} + +fn config_new(env_name: String) -> Config { + { "environment": env_name, "sources_json": "[]" } +} + +fn config_add_source(c: Config, src: ConfigSource) -> Config { + let updated: String = json_array_push_sorted(c.sources_json, json_encode(src), "priority", true) + { "environment": c.environment, "sources_json": updated } +} + +fn config_set_defaults(c: Config, defaults_map: String) -> Config { + config_add_source(c, source_defaults(defaults_map)) +} + +fn config_get_string(c: Config, key: String) -> String { + let n: Int = json_array_len(c.sources_json) + let i: Int = 0 + while i < n { + let src_json: String = json_array_get(c.sources_json, i) + let map: String = json_get(src_json, "map_json") + let v: String = json_get(map, key) + if !str_eq(v, "") { return v } + let i = i + 1 + } + "" +} + +fn config_get_int(c: Config, key: String) -> Int { + let raw: String = config_get_string(c, key) + if str_eq(raw, "") { return 0 } + str_to_int(raw) +} + +fn config_get_bool(c: Config, key: String) -> Bool { + let raw: String = str_to_lower(config_get_string(c, key)) + if str_eq(raw, "true") { return true } + if str_eq(raw, "1") { return true } + if str_eq(raw, "yes") { return true } + false +} + +// Strict variants — non-empty required. +fn config_require_string(c: Config, key: String) -> String { + let v: String = config_get_string(c, key) + if str_eq(v, "") { panic(CFG_ERR_MISSING + ":" + key) } + v +} + +// ── load_from_toml — convenience for TOML config files ────────────────────── + +fn config_load_from_toml(path: String, env_name: String) -> Config { + let cfg: Config = config_new(env_name) + let raw: String = fs_read(path) + let map: String = toml_to_json_flat(raw) // dotted keys + let cfg = config_add_source(cfg, source_defaults(map)) + cfg +} + +// ── .env parser (minimal) ─────────────────────────────────────────────────── + +fn dotenv_parse(raw: String) -> String { + let map: String = "{}" + let lines: String = str_split(raw, "\n") + let n: Int = json_array_len(lines) + let i: Int = 0 + while i < n { + let line: String = str_trim(json_array_get(lines, i)) + if str_eq(line, "") { + let i = i + 1 + } + if !str_eq(line, "") { + if !str_starts_with(line, "#") { + let eq: Int = str_index_of(line, "=") + if eq > 0 { + let k: String = str_trim(str_slice(line, 0, eq)) + let v: String = str_trim(str_slice(line, eq + 1, str_len(line))) + let v = str_strip_quotes(v) + let map = json_set(map, str_to_lower(str_replace(k, "_", ".")), "\"" + v + "\"") + } + let i = i + 1 + } + } + } + map +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let cfg: Config = config_new(env_current()) +let defaults: String = "{\"app.name\":\"MyApp\",\"server.port\":\"8080\"}" +let cfg = config_set_defaults(cfg, defaults) +println("[el-config] env=" + cfg.environment + " app=" + config_get_string(cfg, "app.name")) diff --git a/ui/crates/el-config/src/source.rs b/ui/vessels/el-config/src/source.rs similarity index 100% rename from ui/crates/el-config/src/source.rs rename to ui/vessels/el-config/src/source.rs diff --git a/ui/vessels/el-graph/manifest.el b/ui/vessels/el-graph/manifest.el new file mode 100644 index 0000000..7e2381f --- /dev/null +++ b/ui/vessels/el-graph/manifest.el @@ -0,0 +1,17 @@ +vessel "el-graph" { + version "0.1.0" + description "Graph rendering and editor vessel for el-ui" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-style "1.0" + el-layout "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/vessels/el-graph/src/canvas.el b/ui/vessels/el-graph/src/canvas.el new file mode 100644 index 0000000..0b07c57 --- /dev/null +++ b/ui/vessels/el-graph/src/canvas.el @@ -0,0 +1,46 @@ +// canvas.el — Full server-side pipeline: layout -> render -> SVG string. +// +// This is the primary integration point for callers that want a static SVG +// without managing the layout and render steps separately. +// +// Public API: +// graph_svg_endpoint(nodes_json, edges_json, width, height) -> String +// Full pipeline: Coulomb/spring layout (150 iterations) -> SVG string. +// Returns a complete ... string. +// +// Client-side interaction (drag, zoom, pan) is deferred until el-ui-compiler +// gains a JavaScript backend. For now, all rendering is server-side. +// Clients refresh the SVG on demand (e.g., polling GET /api/graph/svg). +// +// Zoom/pan note: SVG viewBox is fixed to [0,0,width,height]. When the JS +// backend lands, el-ui-compiler will produce an overlay with pointer-event +// handlers that transform a wrapper inside this SVG. The server-side path +// stays as a fallback for non-browser consumers (CLI, PDF export, testing). + +fn layout_default_iterations() -> Int { 150 } + +// ── graph_svg_endpoint ──────────────────────────────────────────────────────── + +fn graph_svg_endpoint(nodes_json: String, edges_json: String, width: Int, height: Int) -> String { + let w_f: Float = int_to_float(width) + let h_f: Float = int_to_float(height) + + // Step 1: compute layout + let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, layout_default_iterations()) + + // Step 2: render to SVG + let svg: String = graph_render_svg(nodes_json, edges_json, positions_json, width, height) + svg +} + +// ── graph_svg_endpoint_custom ───────────────────────────────────────────────── +// +// Same as above but with configurable iteration count. +// Use when you need faster layout (low iters) or higher quality (high iters). + +fn graph_svg_endpoint_custom(nodes_json: String, edges_json: String, width: Int, height: Int, iterations: Int) -> String { + let w_f: Float = int_to_float(width) + let h_f: Float = int_to_float(height) + let positions_json: String = layout_run(nodes_json, edges_json, w_f, h_f, iterations) + graph_render_svg(nodes_json, edges_json, positions_json, width, height) +} diff --git a/ui/vessels/el-graph/src/edge.el b/ui/vessels/el-graph/src/edge.el new file mode 100644 index 0000000..0df469b --- /dev/null +++ b/ui/vessels/el-graph/src/edge.el @@ -0,0 +1,44 @@ +// edge.el — Edge type definitions and visual encoding. +// +// Edges are directed (source -> target) with a weight and optional relation label. + +// ── Edge JSON accessors ────────────────────────────────────────────────────── +// +// Edges are passed as JSON objects: { source_id, target_id, weight, relation } + +fn edge_source(e_json: String) -> String { + let s: String = json_get_string(e_json, "source_id") + if !str_eq(s, "") { return s } + json_get_string(e_json, "source") +} + +fn edge_target(e_json: String) -> String { + let t: String = json_get_string(e_json, "target_id") + if !str_eq(t, "") { return t } + json_get_string(e_json, "target") +} + +fn edge_weight(e_json: String) -> Float { + let w: Float = json_get_float(e_json, "weight") + if w == int_to_float(0) { return int_to_float(1) } + w +} + +fn edge_relation(e_json: String) -> String { + json_get_string(e_json, "relation") +} + +// ── Edge visual encoding ───────────────────────────────────────────────────── + +// Stroke width clamped to [1, 4] based on weight. +fn edge_stroke_width(weight: Float) -> Float { + let min_w: Float = int_to_float(1) + let max_w: Float = int_to_float(4) + let range: Float = max_w - min_w + let clamped: Float = if weight < min_w { min_w } else { if weight > max_w { max_w } else { weight } } + clamped +} + +fn edge_stroke_color() -> String { "#3a4a5a" } + +fn edge_stroke_color_highlight() -> String { "#5a7a9a" } diff --git a/ui/vessels/el-graph/src/editor.el b/ui/vessels/el-graph/src/editor.el new file mode 100644 index 0000000..0d92860 --- /dev/null +++ b/ui/vessels/el-graph/src/editor.el @@ -0,0 +1,138 @@ +// editor.el — Round-trip graph editing API. +// +// Provides El functions for mutating the Engram graph (the live knowledge graph +// stored in-process via engram_* builtins). These functions are the mutation +// layer for graph editors — the CGI Studio Engram panel will call these to add, +// remove, and connect nodes without reloading the whole graph. +// +// All mutations write directly to the in-process Engram via engram_* builtins +// (see BOOTSTRAP.md §Engram Knowledge Graph). +// +// Drag interaction is NOT implemented here — that requires pointer-event +// handlers in JavaScript. When el-ui-compiler gains a JS backend, wire the +// move_node() position cache to the layout state keys used in layout.el. +// +// Public API: +// graph_add_node(content, node_type, label, salience) -> String // node_id or "" +// graph_remove_node(node_id) -> String // "ok" or error JSON +// graph_add_edge(from_id, to_id, weight_str, relation) -> String // "ok" or error JSON +// graph_remove_edge(from_id, to_id) -> String // "ok" or error JSON +// graph_move_node(node_id, x_str, y_str) -> String // "ok" (position cache) + +// ── graph_add_node ──────────────────────────────────────────────────────────── + +fn graph_add_node(content: String, node_type: String, label: String, salience_str: String) -> String { + if str_eq(content, "") { + return "{\"error\":\"content is required\"}" + } + let sal: Float = if str_eq(salience_str, "") { int_to_float(1) } else { str_to_float(salience_str) } + // engram_node_full: content, type, label, salience, importance, confidence, tier, tags + let eff_label: String = if str_eq(label, "") { str_slice(content, 0, 40) } else { label } + let eff_type: String = if str_eq(node_type, "") { "Entity" } else { node_type } + let node_id: String = engram_node_full(content, eff_type, eff_label, sal, sal, int_to_float(1), "Working", "") + if str_eq(node_id, "") { + return "{\"error\":\"engram_node_full returned empty id\"}" + } + "{\"id\":\"" + node_id + "\"}" +} + +// ── graph_remove_node ───────────────────────────────────────────────────────── + +fn graph_remove_node(node_id: String) -> String { + if str_eq(node_id, "") { + return "{\"error\":\"node_id is required\"}" + } + // Check node exists + let existing: String = engram_get_node(node_id) + if str_eq(existing, "") { + return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}" + } + engram_forget(node_id) + "{\"ok\":true,\"id\":\"" + node_id + "\"}" +} + +// ── graph_add_edge ──────────────────────────────────────────────────────────── + +fn graph_add_edge(from_id: String, to_id: String, weight_str: String, relation: String) -> String { + if str_eq(from_id, "") { + return "{\"error\":\"from_id is required\"}" + } + if str_eq(to_id, "") { + return "{\"error\":\"to_id is required\"}" + } + let w: Float = if str_eq(weight_str, "") { int_to_float(1) } else { str_to_float(weight_str) } + let rel: String = if str_eq(relation, "") { "relates_to" } else { relation } + // engram_connect(from, to, weight, relation) + let edge_id: String = engram_connect(from_id, to_id, w, rel) + if str_eq(edge_id, "") { + return "{\"error\":\"engram_connect failed\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}" + } + "{\"ok\":true,\"edge_id\":\"" + edge_id + "\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}" +} + +// ── graph_remove_edge ───────────────────────────────────────────────────────── + +fn graph_remove_edge(from_id: String, to_id: String) -> String { + if str_eq(from_id, "") { + return "{\"error\":\"from_id is required\"}" + } + if str_eq(to_id, "") { + return "{\"error\":\"to_id is required\"}" + } + // Check edge exists + let existing: String = engram_edge_between(from_id, to_id) + if str_eq(existing, "") { + return "{\"error\":\"edge not found\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}" + } + // No engram_remove_edge builtin — use engram_forget on the edge node if + // an edge ID was returned, otherwise surface a not-implemented note. + // In practice, engram_forget(node_id) removes a node and its edges; + // there is no "remove edge only" primitive yet. + "{\"error\":\"remove_edge not yet supported by engram builtins — remove the node to remove all its edges\",\"from\":\"" + from_id + "\",\"to\":\"" + to_id + "\"}" +} + +// ── graph_move_node ─────────────────────────────────────────────────────────── +// +// Caches a node's screen position for use by the layout engine. +// When el-ui-compiler ships JS output, drag handlers will call this after +// pointer-up to persist the dragged position so the next render uses it as +// the initial position (preventing snap-back after re-layout). + +fn graph_move_node(node_id: String, x_str: String, y_str: String) -> String { + if str_eq(node_id, "") { + return "{\"error\":\"node_id is required\"}" + } + // Store in process state — layout.el reads these keys as initial positions. + state_set("node_x_" + node_id, x_str) + state_set("node_y_" + node_id, y_str) + // Also zero the velocity so the node doesn't immediately drift. + state_set("node_vx_" + node_id, "0.0") + state_set("node_vy_" + node_id, "0.0") + "{\"ok\":true,\"id\":\"" + node_id + "\",\"x\":" + x_str + ",\"y\":" + y_str + "}" +} + +// ── graph_node_info ─────────────────────────────────────────────────────────── +// +// Retrieve full node data from Engram (for inspector panels). + +fn graph_node_info(node_id: String) -> String { + if str_eq(node_id, "") { + return "{\"error\":\"node_id is required\"}" + } + let n: String = engram_get_node(node_id) + if str_eq(n, "") { + return "{\"error\":\"node not found\",\"id\":\"" + node_id + "\"}" + } + n +} + +// ── graph_neighbors ─────────────────────────────────────────────────────────── +// +// Return the neighbors of a node as a JSON array (for sub-graph drill-down). + +fn graph_neighbors_json(node_id: String) -> String { + if str_eq(node_id, "") { + return "{\"error\":\"node_id is required\"}" + } + engram_neighbors(node_id) +} diff --git a/ui/vessels/el-graph/src/layout.el b/ui/vessels/el-graph/src/layout.el new file mode 100644 index 0000000..93f7878 --- /dev/null +++ b/ui/vessels/el-graph/src/layout.el @@ -0,0 +1,352 @@ +// layout.el — Force-directed layout engine (pure El math). +// +// Implements a basic spring-force simulation: +// - Coulomb repulsion between every pair of nodes (O(n²)) +// - Hooke spring attraction along edges +// - Weak gravity toward the canvas center +// - Velocity damping per iteration +// +// Float representation: El stores floats as bit-cast int64_t values. +// All math uses int_to_float() for literals and math_sqrt() for sqrt. +// +// Public API: +// layout_run(nodes_json, edges_json, width, height, iterations) -> String +// nodes_json — JSON array: [{ id, salience, ... }, ...] +// edges_json — JSON array: [{ source_id, target_id, weight }, ...] +// width/height — canvas Float dimensions +// iterations — simulation steps (default 150 for good convergence) +// Returns JSON array: [{ id, x, y }, ...] + +// ── Constants ──────────────────────────────────────────────────────────────── + +fn layout_repulsion_k() -> Float { + // Coulomb constant — controls node spread. + int_to_float(3000) +} + +fn layout_spring_k() -> Float { + // Spring stiffness for edge attraction. + int_to_float(1) +} + +fn layout_spring_rest() -> Float { + // Rest length for edges (px). + int_to_float(80) +} + +fn layout_gravity_k() -> Float { + // Gravity toward center (gentle). + int_to_float(1) +} + +fn layout_damping() -> Float { + // Velocity decay per step (0.85 = 15% loss per step). + let d: Float = int_to_float(85) + d / int_to_float(100) +} + +fn layout_max_velocity() -> Float { + // Cap velocity per step to avoid explosion. + int_to_float(50) +} + +fn layout_min_dist() -> Float { + // Minimum distance to prevent division by zero in repulsion. + int_to_float(1) +} + +// ── State keys (process state for per-node data) ───────────────────────────── +// +// We use process state (state_set/state_get) as a flat key/value store since +// El does not have mutable arrays or map mutation without re-assignment. +// Key patterns: +// "node_ids" — comma-separated node id list +// "node_x_" — x position +// "node_y_" — y position +// "node_vx_" — x velocity +// "node_vy_" — y velocity + +fn layout_key_x(node_id: String) -> String { "node_x_" + node_id } +fn layout_key_y(node_id: String) -> String { "node_y_" + node_id } +fn layout_key_vx(node_id: String) -> String { "node_vx_" + node_id } +fn layout_key_vy(node_id: String) -> String { "node_vy_" + node_id } + +// ── Initialization ─────────────────────────────────────────────────────────── +// +// Distribute nodes in a circle around the center so no two start at the +// same position (which would make repulsion forces zero and give no movement). + +fn layout_init_positions(node_ids: String, cx: Float, cy: Float) -> Bool { + let ids: [String] = str_split(node_ids, ",") + let count: Int = el_list_len(ids) + if count == 0 { return true } + let pi2: Float = math_pi() * int_to_float(2) + let radius: Float = int_to_float(100) + int_to_float(20) * int_to_float(count) + let i: Int = 0 + while i < count { + let id: String = el_list_get(ids, i) + let angle: Float = pi2 * int_to_float(i) / int_to_float(count) + let x: Float = cx + radius * math_cos(angle) + let y: Float = cy + math_sin(angle) * radius + state_set(layout_key_x(id), float_to_str(x)) + state_set(layout_key_y(id), float_to_str(x)) + state_set(layout_key_y(id), float_to_str(y)) + state_set(layout_key_vx(id), "0.0") + state_set(layout_key_vy(id), "0.0") + let i = i + 1 + } + true +} + +// ── Float helpers ───────────────────────────────────────────────────────────── + +fn layout_get_x(id: String) -> Float { + str_to_float(state_get(layout_key_x(id))) +} + +fn layout_get_y(id: String) -> Float { + str_to_float(state_get(layout_key_y(id))) +} + +fn layout_get_vx(id: String) -> Float { + str_to_float(state_get(layout_key_vx(id))) +} + +fn layout_get_vy(id: String) -> Float { + str_to_float(state_get(layout_key_vy(id))) +} + +fn float_clamp(v: Float, lo: Float, hi: Float) -> Float { + if v < lo { return lo } + if v > hi { return hi } + v +} + +fn float_abs(v: Float) -> Float { + if v < int_to_float(0) { return int_to_float(0) - v } + v +} + +// ── Repulsion pass ──────────────────────────────────────────────────────────── +// +// For each pair (a, b): compute Coulomb repulsion and accumulate forces. +// Force direction: along the vector from b to a (a is pushed away from b). +// Magnitude: k / dist^2 + +fn layout_repulsion_pass(node_ids: String) -> Bool { + let ids: [String] = str_split(node_ids, ",") + let n: Int = el_list_len(ids) + let i: Int = 0 + while i < n { + let id_a: String = el_list_get(ids, i) + let ax: Float = layout_get_x(id_a) + let ay: Float = layout_get_y(id_a) + let fx: Float = int_to_float(0) + let fy: Float = int_to_float(0) + let j: Int = 0 + while j < n { + if j != i { + let id_b: String = el_list_get(ids, j) + let bx: Float = layout_get_x(id_b) + let by: Float = layout_get_y(id_b) + let dx: Float = ax - bx + let dy: Float = ay - by + let dist_sq: Float = dx * dx + dy * dy + let dist: Float = math_sqrt(dist_sq) + let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist } + let force: Float = layout_repulsion_k() / (safe_dist * safe_dist) + let nx: Float = dx / safe_dist + let ny: Float = dy / safe_dist + let fx = fx + nx * force + let fy = fy + ny * force + } + let j = j + 1 + } + // Accumulate: store forces temporarily in velocity (they are scaled later) + // Use "fx_" keys for accumulation. + state_set("fx_" + id_a, float_to_str(fx)) + state_set("fy_" + id_a, float_to_str(fy)) + let i = i + 1 + } + true +} + +// ── Spring pass ─────────────────────────────────────────────────────────────── +// +// For each edge (a->b): apply Hooke spring toward rest length. +// Both endpoints feel the force (attractive when dist > rest, repulsive when < rest). + +fn layout_spring_pass(node_ids: String, edges_json: String) -> Bool { + let edge_count: Int = json_array_len(edges_json) + let i: Int = 0 + while i < edge_count { + let e: String = json_array_get(edges_json, i) + let src: String = json_get_string(e, "source_id") + let tgt_raw: String = json_get_string(e, "target_id") + // Support both source_id/target_id and source/target field names + let src2: String = if str_eq(src, "") { json_get_string(e, "source") } else { src } + let tgt2: String = if str_eq(tgt_raw, "") { json_get_string(e, "target") } else { tgt_raw } + let w: Float = json_get_float(e, "weight") + let eff_w: Float = if w == int_to_float(0) { int_to_float(1) } else { w } + + // Only apply spring if both endpoints are in our node set + let sx: String = state_get(layout_key_x(src2)) + let tx_chk: String = state_get(layout_key_x(tgt2)) + if !str_eq(sx, "") { + if !str_eq(tx_chk, "") { + let ax: Float = layout_get_x(src2) + let ay: Float = layout_get_y(src2) + let bx: Float = layout_get_x(tgt2) + let by_val: Float = layout_get_y(tgt2) + let dx: Float = bx - ax + let dy: Float = by_val - ay + let dist_sq: Float = dx * dx + dy * dy + let dist: Float = math_sqrt(dist_sq) + let safe_dist: Float = if dist < layout_min_dist() { layout_min_dist() } else { dist } + let stretch: Float = (safe_dist - layout_spring_rest()) * layout_spring_k() * eff_w + let nx: Float = dx / safe_dist + let ny: Float = dy / safe_dist + let spring_fx: Float = nx * stretch + let spring_fy: Float = ny * stretch + + // Add to accumulated forces + let cur_fx_a: Float = str_to_float(state_get("fx_" + src2)) + let cur_fy_a: Float = str_to_float(state_get("fy_" + src2)) + state_set("fx_" + src2, float_to_str(cur_fx_a + spring_fx)) + state_set("fy_" + src2, float_to_str(cur_fy_a + spring_fy)) + + let cur_fx_b: Float = str_to_float(state_get("fx_" + tgt2)) + let cur_fy_b: Float = str_to_float(state_get("fy_" + tgt2)) + state_set("fx_" + tgt2, float_to_str(cur_fx_b - spring_fx)) + state_set("fy_" + tgt2, float_to_str(cur_fy_b - spring_fy)) + } + } + let i = i + 1 + } + true +} + +// ── Gravity pass ────────────────────────────────────────────────────────────── +// +// Weak attraction toward canvas center to prevent isolated nodes from drifting. + +fn layout_gravity_pass(node_ids: String, cx: Float, cy: Float) -> Bool { + let ids: [String] = str_split(node_ids, ",") + let n: Int = el_list_len(ids) + let i: Int = 0 + while i < n { + let id: String = el_list_get(ids, i) + let x: Float = layout_get_x(id) + let y: Float = layout_get_y(id) + let gx: Float = (cx - x) * layout_gravity_k() / int_to_float(100) + let gy: Float = (cy - y) * layout_gravity_k() / int_to_float(100) + let cur_fx: Float = str_to_float(state_get("fx_" + id)) + let cur_fy: Float = str_to_float(state_get("fy_" + id)) + state_set("fx_" + id, float_to_str(cur_fx + gx)) + state_set("fy_" + id, float_to_str(cur_fy + gy)) + let i = i + 1 + } + true +} + +// ── Integration pass ────────────────────────────────────────────────────────── +// +// Apply forces to velocities (with damping), then update positions. +// Clamp positions to stay within canvas bounds (with 20px margin). + +fn layout_integrate(node_ids: String, width: Float, height: Float) -> Bool { + let ids: [String] = str_split(node_ids, ",") + let n: Int = el_list_len(ids) + let max_v: Float = layout_max_velocity() + let damp: Float = layout_damping() + let margin: Float = int_to_float(20) + let i: Int = 0 + while i < n { + let id: String = el_list_get(ids, i) + let vx: Float = (layout_get_vx(id) + str_to_float(state_get("fx_" + id))) * damp + let vy: Float = (layout_get_vy(id) + str_to_float(state_get("fy_" + id))) * damp + // Clamp velocity magnitude + let vx_clamped: Float = float_clamp(vx, int_to_float(0) - max_v, max_v) + let vy_clamped: Float = float_clamp(vy, int_to_float(0) - max_v, max_v) + let new_x: Float = float_clamp(layout_get_x(id) + vx_clamped, margin, width - margin) + let new_y: Float = float_clamp(layout_get_y(id) + vy_clamped, margin, height - margin) + state_set(layout_key_x(id), float_to_str(new_x)) + state_set(layout_key_y(id), float_to_str(new_y)) + state_set(layout_key_vx(id), float_to_str(vx_clamped)) + state_set(layout_key_vy(id), float_to_str(vy_clamped)) + // Reset force accumulators for next iteration + state_set("fx_" + id, "0.0") + state_set("fy_" + id, "0.0") + let i = i + 1 + } + true +} + +// ── Public: layout_run ──────────────────────────────────────────────────────── +// +// Full pipeline: init positions, run N iterations, return positions as JSON. +// +// Input nodes_json must be a JSON array of objects with at least an "id" field. +// Returns: JSON array [{ "id": "...", "x": 123.0, "y": 456.0 }, ...] + +fn layout_run(nodes_json: String, edges_json: String, width: Float, height: Float, iterations: Int) -> String { + let cx: Float = width / int_to_float(2) + let cy: Float = height / int_to_float(2) + + // Build comma-separated node_ids list + let node_count: Int = json_array_len(nodes_json) + if node_count == 0 { return "[]" } + + let node_ids: String = "" + let first: Bool = true + let i: Int = 0 + while i < node_count { + let n: String = json_array_get(nodes_json, i) + let id: String = json_get_string(n, "id") + if !str_eq(id, "") { + if first { + let node_ids = id + let first = false + } else { + let node_ids = node_ids + "," + id + } + // Pre-initialize force accumulators + state_set("fx_" + id, "0.0") + state_set("fy_" + id, "0.0") + } + let i = i + 1 + } + + // Initialize positions (circle around center) + layout_init_positions(node_ids, cx, cy) + + // Simulation loop + let iter: Int = 0 + while iter < iterations { + layout_repulsion_pass(node_ids) + layout_spring_pass(node_ids, edges_json) + layout_gravity_pass(node_ids, cx, cy) + layout_integrate(node_ids, width, height) + let iter = iter + 1 + } + + // Collect results as JSON array + let result: String = "[" + let ids: [String] = str_split(node_ids, ",") + let n2: Int = el_list_len(ids) + let j: Int = 0 + while j < n2 { + let id: String = el_list_get(ids, j) + let x: Float = layout_get_x(id) + let y: Float = layout_get_y(id) + let entry: String = "{\"id\":\"" + id + "\",\"x\":" + format_float(x, 1) + ",\"y\":" + format_float(y, 1) + "}" + if j == 0 { + let result = result + entry + } else { + let result = result + "," + entry + } + let j = j + 1 + } + let result = result + "]" + result +} diff --git a/ui/vessels/el-graph/src/main.el b/ui/vessels/el-graph/src/main.el new file mode 100644 index 0000000..e26d209 --- /dev/null +++ b/ui/vessels/el-graph/src/main.el @@ -0,0 +1,34 @@ +// main.el — el-graph vessel entry point. +// +// Re-exports all public functions from the sub-modules. The vessel is +// compiled as a single translation unit (all imports are concatenated by +// the build harness before elc runs). This file is the canonical import +// target for downstream consumers. +// +// Import order matters only for readability — elc emits forward declarations +// for all top-level functions so any order compiles correctly. + +import "node.el" +import "edge.el" +import "layout.el" +import "view.el" +import "canvas.el" +import "editor.el" +import "serializer.el" + +// ── Smoke test ──────────────────────────────────────────────────────────────── +// +// Verifies the vessel initializes correctly. Runs a minimal 2-node layout +// and checks that the output is a non-empty JSON array. +// +// This runs at module load time (top-level El statements execute sequentially). +// Remove or gate behind an env flag if startup overhead matters. + +println("[el-graph] v0.1.0 — force layout + SVG renderer") +println("[el-graph] node_color(Memory) = " + node_color("Memory")) +println("[el-graph] node_radius(0.8) = " + int_to_str(node_radius_int(int_to_float(8) / int_to_float(10)))) + +let _smoke_nodes: String = "[{\"id\":\"a\",\"salience\":0.8,\"node_type\":\"Memory\"},{\"id\":\"b\",\"salience\":0.5,\"node_type\":\"Entity\"}]" +let _smoke_edges: String = "[{\"source_id\":\"a\",\"target_id\":\"b\",\"weight\":1.0}]" +let _smoke_pos: String = layout_run(_smoke_nodes, _smoke_edges, int_to_float(400), int_to_float(300), 10) +println("[el-graph] smoke layout (10 iter) = " + str_slice(_smoke_pos, 0, 60) + "...") diff --git a/ui/vessels/el-graph/src/node.el b/ui/vessels/el-graph/src/node.el new file mode 100644 index 0000000..f71f6e8 --- /dev/null +++ b/ui/vessels/el-graph/src/node.el @@ -0,0 +1,77 @@ +// node.el — Node type definitions and color/radius mapping. +// +// Node types mirror the Engram knowledge graph node_type field. +// Colors are chosen for dark-background (Studio) legibility. + +// ── Node type constants ────────────────────────────────────────────────────── + +fn node_type_memory() -> String { "Memory" } +fn node_type_backlog() -> String { "BacklogItem" } +fn node_type_knowledge() -> String { "Knowledge" } +fn node_type_entity() -> String { "Entity" } +fn node_type_default() -> String { "Node" } + +// ── Color map ──────────────────────────────────────────────────────────────── + +fn node_color(node_type: String) -> String { + if str_eq(node_type, "Memory") { return "#58A6FF" } + if str_eq(node_type, "BacklogItem") { return "#C9A84C" } + if str_eq(node_type, "Knowledge") { return "#2ecc71" } + if str_eq(node_type, "Entity") { return "#e74c3c" } + if str_eq(node_type, "WorkContext") { return "#9b59b6" } + if str_eq(node_type, "Artifact") { return "#1abc9c" } + if str_eq(node_type, "Process") { return "#e67e22" } + "#7a8ba8" +} + +// ── Radius ─────────────────────────────────────────────────────────────────── +// +// Clamp salience (0.0–1.0) to radius range [6, 18]. + +fn node_radius(salience: Float) -> Float { + let min_r: Float = int_to_float(6) + let max_r: Float = int_to_float(18) + let range: Float = max_r - min_r + let clamped: Float = if salience < int_to_float(0) { int_to_float(0) } else { if salience > int_to_float(1) { int_to_float(1) } else { salience } } + min_r + range * clamped +} + +fn node_radius_int(salience: Float) -> Int { + float_to_int(node_radius(salience)) +} + +// ── Label truncation ───────────────────────────────────────────────────────── + +fn node_label_truncate(label: String) -> String { + let max_len: Int = 30 + let l: Int = str_len(label) + if l <= max_len { return label } + str_slice(label, 0, max_len) + "..." +} + +// ── Node JSON accessors ────────────────────────────────────────────────────── +// +// Nodes are passed as JSON objects: { id, label, node_type, salience, ... } + +fn node_id(n_json: String) -> String { + json_get_string(n_json, "id") +} + +fn node_label(n_json: String) -> String { + let lbl: String = json_get_string(n_json, "label") + if !str_eq(lbl, "") { return lbl } + // Fall back to first 40 chars of content + let c: String = json_get_string(n_json, "content") + if str_len(c) > 40 { return str_slice(c, 0, 40) } + c +} + +fn node_type_field(n_json: String) -> String { + let t: String = json_get_string(n_json, "node_type") + if str_eq(t, "") { return node_type_default() } + t +} + +fn node_salience(n_json: String) -> Float { + json_get_float(n_json, "salience") +} diff --git a/ui/vessels/el-graph/src/serializer.el b/ui/vessels/el-graph/src/serializer.el new file mode 100644 index 0000000..6cb4b6d --- /dev/null +++ b/ui/vessels/el-graph/src/serializer.el @@ -0,0 +1,80 @@ +// serializer.el — Export graph as SVG string or portable JSON. +// +// Public API: +// graph_to_svg(graph_json, width, height) -> String +// graph_json: { "nodes": [...], "edges": [...] } +// Full pipeline: parse -> layout -> render -> SVG string. +// +// graph_to_json(nodes_json, edges_json, positions_json) -> String +// Portable export combining node data with computed positions. +// Useful for saving layouts to disk or sending to other tools. + +// ── graph_to_svg ────────────────────────────────────────────────────────────── +// +// Convenience wrapper: accepts a combined graph JSON object and returns SVG. + +fn graph_to_svg(graph_json: String, width: Int, height: Int) -> String { + let nodes_raw: String = json_get_raw(graph_json, "nodes") + let edges_raw: String = json_get_raw(graph_json, "edges") + let nodes_json: String = if str_eq(nodes_raw, "") { "[]" } else { nodes_raw } + let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw } + graph_svg_endpoint(nodes_json, edges_json, width, height) +} + +// ── graph_to_json ───────────────────────────────────────────────────────────── +// +// Merge node metadata with computed positions into a portable export format. +// Output: { "nodes": [{...node fields..., "x": 123.0, "y": 456.0}], "edges": [...] } + +fn graph_to_json(nodes_json: String, edges_json: String, positions_json: String) -> String { + // Index positions by id + build_position_index(positions_json) + + let node_count: Int = json_array_len(nodes_json) + let nodes_out: String = "[" + let i: Int = 0 + while i < node_count { + let n: String = json_array_get(nodes_json, i) + let id: String = json_get_string(n, "id") + let x: Float = get_pos_x(id) + let y: Float = get_pos_y(id) + // Inject x/y into the node JSON + let n_with_pos: String = json_set(json_set(n, "x", format_float(x, 1)), "y", format_float(y, 1)) + if i == 0 { + let nodes_out = nodes_out + n_with_pos + } else { + let nodes_out = nodes_out + "," + n_with_pos + } + let i = i + 1 + } + let nodes_out = nodes_out + "]" + + "{\"nodes\":" + nodes_out + ",\"edges\":" + edges_json + "}" +} + +// ── graph_snapshot_svg ──────────────────────────────────────────────────────── +// +// Render a snapshot of the current in-process Engram graph as SVG. +// Uses engram_scan_nodes_json and reads edges from the snapshot file. +// This is the function called by the CGI Studio /api/graph/svg endpoint. + +fn graph_snapshot_svg(width: Int, height: Int, snap_path: String) -> String { + let nodes_json: String = engram_scan_nodes_json(9999, 0) + let n_count: Int = json_array_len(nodes_json) + + // Read edges from snapshot file + let snap: String = fs_read(snap_path) + let edges_raw: String = if str_eq(snap, "") { "[]" } else { json_get_raw(snap, "edges") } + let edges_json: String = if str_eq(edges_raw, "") { "[]" } else { edges_raw } + + if n_count == 0 { + // Return an empty SVG with a "no data" message + return svg_open(width, height) + + "No nodes in graph" + + svg_close() + } + + graph_svg_endpoint(nodes_json, edges_json, width, height) +} diff --git a/ui/vessels/el-graph/src/view.el b/ui/vessels/el-graph/src/view.el new file mode 100644 index 0000000..1524d28 --- /dev/null +++ b/ui/vessels/el-graph/src/view.el @@ -0,0 +1,155 @@ +// view.el — SVG renderer for the force-directed graph. +// +// Takes layout positions + node/edge data and produces a complete SVG string. +// Rendering is purely server-side — no DOM, no JavaScript. +// +// Public API: +// graph_render_svg(nodes_json, edges_json, positions_json, width, height) -> String +// Returns a complete ... string ready for embedding or serving. +// +// Visual conventions: +// - Background: #0d1117 (dark, matching Studio theme) +// - Edges drawn first (below nodes) +// - Nodes: filled circle with stroke, radius by salience +// - Labels: truncated to 30 chars, below node, 10px IBM Plex Mono + +// ── SVG helpers ─────────────────────────────────────────────────────────────── + +fn svg_open(width: Int, height: Int) -> String { + "" +} + +fn svg_close() -> String { "" } + +fn svg_defs() -> String { + "" + + "" + + "" + + "" +} + +// ── Edge rendering ──────────────────────────────────────────────────────────── + +fn svg_edge(x1: Float, y1: Float, x2: Float, y2: Float, weight: Float) -> String { + let sw: Float = edge_stroke_width(weight) + let sw_str: String = format_float(sw, 1) + let x1s: String = format_float(x1, 1) + let y1s: String = format_float(y1, 1) + let x2s: String = format_float(x2, 1) + let y2s: String = format_float(y2, 1) + "" +} + +// ── Node rendering ──────────────────────────────────────────────────────────── + +fn svg_node(x: Float, y: Float, radius: Int, color: String, label: String) -> String { + let xs: String = format_float(x, 1) + let ys: String = format_float(y, 1) + let rs: String = int_to_str(radius) + let label_trunc: String = node_label_truncate(label) + // Escape XML special chars in label + let label_safe: String = str_replace(str_replace(str_replace(label_trunc, "&", "&"), "<", "<"), ">", ">") + let label_y: String = format_float(y + int_to_float(radius) + int_to_float(12), 1) + "" + + "" + label_safe + "" +} + +// ── Position lookup ─────────────────────────────────────────────────────────── +// +// Build a flat map from node_id -> position JSON in process state. +// Key: "pos_" -> "{\"x\":...,\"y\":...}" + +fn build_position_index(positions_json: String) -> Bool { + let count: Int = json_array_len(positions_json) + let i: Int = 0 + while i < count { + let pos: String = json_array_get(positions_json, i) + let id: String = json_get_string(pos, "id") + if !str_eq(id, "") { + state_set("pos_" + id, pos) + } + let i = i + 1 + } + true +} + +fn get_pos_x(node_id: String) -> Float { + let pos: String = state_get("pos_" + node_id) + if str_eq(pos, "") { return int_to_float(0) } + json_get_float(pos, "x") +} + +fn get_pos_y(node_id: String) -> Float { + let pos: String = state_get("pos_" + node_id) + if str_eq(pos, "") { return int_to_float(0) } + json_get_float(pos, "y") +} + +// ── Public: graph_render_svg ────────────────────────────────────────────────── + +fn graph_render_svg(nodes_json: String, edges_json: String, positions_json: String, width: Int, height: Int) -> String { + // Index positions by node id + build_position_index(positions_json) + + let out: String = svg_open(width, height) + let out = out + svg_defs() + + // ── Draw edges (behind nodes) ───────────────────────────────────────────── + let edge_count: Int = json_array_len(edges_json) + let i: Int = 0 + while i < edge_count { + let e: String = json_array_get(edges_json, i) + let src: String = edge_source(e) + let tgt: String = edge_target(e) + let w: Float = edge_weight(e) + // Only draw if both endpoints have positions + let src_pos: String = state_get("pos_" + src) + let tgt_pos: String = state_get("pos_" + tgt) + if !str_eq(src_pos, "") { + if !str_eq(tgt_pos, "") { + let x1: Float = get_pos_x(src) + let y1: Float = get_pos_y(src) + let x2: Float = get_pos_x(tgt) + let y2: Float = get_pos_y(tgt) + let out = out + svg_edge(x1, y1, x2, y2, w) + } + } + let i = i + 1 + } + + // ── Draw nodes (over edges) ─────────────────────────────────────────────── + let node_count: Int = json_array_len(nodes_json) + let j: Int = 0 + while j < node_count { + let n: String = json_array_get(nodes_json, j) + let id: String = node_id(n) + let lbl: String = node_label(n) + let ntype: String = node_type_field(n) + let sal: Float = node_salience(n) + let color: String = node_color(ntype) + let radius: Int = node_radius_int(sal) + let pos: String = state_get("pos_" + id) + if !str_eq(pos, "") { + let x: Float = get_pos_x(id) + let y: Float = get_pos_y(id) + let out = out + svg_node(x, y, radius, color, lbl) + } + let j = j + 1 + } + + let out = out + svg_close() + out +} diff --git a/ui/vessels/el-html/manifest.el b/ui/vessels/el-html/manifest.el new file mode 100644 index 0000000..5156e77 --- /dev/null +++ b/ui/vessels/el-html/manifest.el @@ -0,0 +1,17 @@ +vessel "el-html" { + version "0.1.0" + description "HTML generation and templating vessel for el-ui" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-style "1.0" + el-layout "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/vessels/el-html/src/main.el b/ui/vessels/el-html/src/main.el new file mode 100644 index 0000000..9da7e58 --- /dev/null +++ b/ui/vessels/el-html/src/main.el @@ -0,0 +1,222 @@ +// el-html — Atomic HTML element primitives. +// +// Usage: +// el_div("class=\"card\"", inner_html) +// el_h1("", "Hello world") +// el_img("/logo.png", "Logo", "width=\"120\"") +// +// Children args are raw HTML — pre-escaped by caller. +// Use el_text(s) to safely insert plain text. + +// ── Text escaping ───────────────────────────────────────────────────────────── + +fn el_escape(s: String) -> String { + let s = str_replace(s, "&", "&") + let s = str_replace(s, "<", "<") + let s = str_replace(s, ">", ">") + let s = str_replace(s, "\"", """) + str_replace(s, "'", "'") +} + +fn el_text(s: String) -> String { + el_escape(s) +} + +fn el_attr(name: String, value: String) -> String { + " " + name + "=\"" + el_escape(value) + "\"" +} + +// ── Block elements ──────────────────────────────────────────────────────────── + +fn el_div(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
" + children + "
" } + "
" + children + "
" +} + +fn el_section(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
" + children + "
" } + "
" + children + "
" +} + +fn el_article(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
" + children + "
" } + "
" + children + "
" +} + +fn el_header(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
" + children + "
" } + "
" + children + "
" +} + +fn el_footer(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
" + children + "
" } + "
" + children + "
" +} + +fn el_main(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
" + children + "
" } + "
" + children + "
" +} + +fn el_nav(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "" } + "" +} + +fn el_aside(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "" } + "" +} + +fn el_ul(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
    " + children + "
" } + "
    " + children + "
" +} + +fn el_ol(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
    " + children + "
" } + "
    " + children + "
" +} + +fn el_li(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
  • " + children + "
  • " } + "
  • " + children + "
  • " +} + +fn el_p(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "

    " + children + "

    " } + "

    " + children + "

    " +} + +fn el_span(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "" + children + "" } + "" + children + "" +} + +fn el_form(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
    " + children + "
    " } + "
    " + children + "
    " +} + +// ── Headings ────────────────────────────────────────────────────────────────── + +fn el_h1(attrs: String, text: String) -> String { + if str_eq(attrs, "") { return "

    " + el_escape(text) + "

    " } + "

    " + el_escape(text) + "

    " +} + +fn el_h2(attrs: String, text: String) -> String { + if str_eq(attrs, "") { return "

    " + el_escape(text) + "

    " } + "

    " + el_escape(text) + "

    " +} + +fn el_h3(attrs: String, text: String) -> String { + if str_eq(attrs, "") { return "

    " + el_escape(text) + "

    " } + "

    " + el_escape(text) + "

    " +} + +fn el_h4(attrs: String, text: String) -> String { + if str_eq(attrs, "") { return "

    " + el_escape(text) + "

    " } + "

    " + el_escape(text) + "

    " +} + +// ── Interactive ─────────────────────────────────────────────────────────────── + +fn el_button(attrs: String, label: String) -> String { + if str_eq(attrs, "") { return "" } + "" +} + +fn el_a(href: String, attrs: String, children: String) -> String { + let h: String = "href=\"" + el_escape(href) + "\"" + if str_eq(attrs, "") { return "" + children + "" } + "" + children + "" +} + +fn el_input(type_attr: String, attrs: String) -> String { + if str_eq(attrs, "") { return "" } + "" +} + +fn el_textarea(attrs: String, value: String) -> String { + if str_eq(attrs, "") { return "" } + "" +} + +fn el_label(for_id: String, attrs: String, children: String) -> String { + let f: String = "for=\"" + el_escape(for_id) + "\"" + if str_eq(attrs, "") { return "" } + "" +} + +// ── Media ───────────────────────────────────────────────────────────────────── + +fn el_img(src: String, alt: String, attrs: String) -> String { + let base: String = "src=\"" + el_escape(src) + "\" alt=\"" + el_escape(alt) + "\"" + if str_eq(attrs, "") { return "" } + "" +} + +fn el_video(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "" } + "" +} + +// ── Inline semantic ─────────────────────────────────────────────────────────── + +fn el_strong(children: String) -> String { + "" + children + "" +} + +fn el_em(children: String) -> String { + "" + children + "" +} + +fn el_code(children: String) -> String { + "" + children + "" +} + +fn el_pre(attrs: String, children: String) -> String { + if str_eq(attrs, "") { return "
    " + children + "
    " } + "
    " + children + "
    " +} + +fn el_hr() -> String { "
    " } +fn el_br() -> String { "
    " } + +// ── Document shell ──────────────────────────────────────────────────────────── + +fn el_html_doc(lang: String, head_html: String, body_html: String) -> String { + "" + + head_html + "" + body_html + "" +} + +fn el_meta(name: String, content: String) -> String { + "" +} + +fn el_meta_charset(charset: String) -> String { + "" +} + +fn el_link_stylesheet(href: String) -> String { + "" +} + +fn el_script_src(src: String, defer_load: Bool) -> String { + if defer_load { return "" } + "" +} + +fn el_script_inline(js: String) -> String { + "" +} + +fn el_title(text: String) -> String { + "" + el_escape(text) + "" +} + +// ── Entry — smoke test ──────────────────────────────────────────────────────── + +let sample: String = el_div("class=\"card\"", el_h2("", "Hello") + el_p("", "World")) +println("[el-html] sample = " + sample) diff --git a/ui/crates/el-i18n/Cargo.toml b/ui/vessels/el-i18n/Cargo.toml similarity index 100% rename from ui/crates/el-i18n/Cargo.toml rename to ui/vessels/el-i18n/Cargo.toml diff --git a/ui/vessels/el-i18n/manifest.el b/ui/vessels/el-i18n/manifest.el new file mode 100644 index 0000000..bbb02f7 --- /dev/null +++ b/ui/vessels/el-i18n/manifest.el @@ -0,0 +1,15 @@ +vessel "el-i18n" { + version "0.1.0" + description "Localization: locale, plural forms, translation bundles, number/currency formatting" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-i18n/src/bundle.rs b/ui/vessels/el-i18n/src/bundle.rs similarity index 100% rename from ui/crates/el-i18n/src/bundle.rs rename to ui/vessels/el-i18n/src/bundle.rs diff --git a/ui/crates/el-i18n/src/format.rs b/ui/vessels/el-i18n/src/format.rs similarity index 100% rename from ui/crates/el-i18n/src/format.rs rename to ui/vessels/el-i18n/src/format.rs diff --git a/ui/crates/el-i18n/src/lib.rs b/ui/vessels/el-i18n/src/lib.rs similarity index 100% rename from ui/crates/el-i18n/src/lib.rs rename to ui/vessels/el-i18n/src/lib.rs diff --git a/ui/crates/el-i18n/src/locale.rs b/ui/vessels/el-i18n/src/locale.rs similarity index 100% rename from ui/crates/el-i18n/src/locale.rs rename to ui/vessels/el-i18n/src/locale.rs diff --git a/ui/vessels/el-i18n/src/main.el b/ui/vessels/el-i18n/src/main.el new file mode 100644 index 0000000..56e7158 --- /dev/null +++ b/ui/vessels/el-i18n/src/main.el @@ -0,0 +1,208 @@ +// el-i18n — Localization for el-ui. +// +// Two-letter language tag + optional region: en, en-US, ar-EG, zh-Hant. +// Bundles map keys to either a plain string or a plural-form map. +// `t(key)` and `t_plural(key, count)` are the surface API. + +// ── Locale ─────────────────────────────────────────────────────────────────── + +let DIR_LTR: String = "ltr" +let DIR_RTL: String = "rtl" + +type Locale { + language: String // "en" + region: String // "US" (may be empty) + direction: String // "ltr" | "rtl" +} + +fn locale_new(language: String, region: String) -> Locale { + let dir: String = "ltr" + if str_eq(language, "ar") { let dir = "rtl" } + if str_eq(language, "he") { let dir = "rtl" } + if str_eq(language, "fa") { let dir = "rtl" } + if str_eq(language, "ur") { let dir = "rtl" } + { "language": language, "region": region, "direction": dir } +} + +fn locale_en_us() -> Locale { locale_new("en", "US") } +fn locale_es_es() -> Locale { locale_new("es", "ES") } +fn locale_zh_cn() -> Locale { locale_new("zh", "CN") } +fn locale_ar_eg() -> Locale { locale_new("ar", "EG") } +fn locale_ja_jp() -> Locale { locale_new("ja", "JP") } + +fn locale_tag(loc: Locale) -> String { + if str_eq(loc.region, "") { return loc.language } + loc.language + "-" + loc.region +} + +fn is_rtl(loc: Locale) -> Bool { + str_eq(loc.direction, "rtl") +} + +// ── Plural forms (CLDR cardinal categories) ────────────────────────────────── +// +// Categories: zero, one, two, few, many, other. +// Most languages only use one + other; Arabic uses all six. + +let PLURAL_ZERO: String = "zero" +let PLURAL_ONE: String = "one" +let PLURAL_TWO: String = "two" +let PLURAL_FEW: String = "few" +let PLURAL_MANY: String = "many" +let PLURAL_OTHER: String = "other" + +fn plural_form(loc: Locale, n: Int) -> String { + if str_eq(loc.language, "ar") { return plural_form_arabic(n) } + if str_eq(loc.language, "ru") { return plural_form_russian(n) } + if str_eq(loc.language, "pl") { return plural_form_polish(n) } + if str_eq(loc.language, "ja") { return PLURAL_OTHER } + if str_eq(loc.language, "zh") { return PLURAL_OTHER } + if str_eq(loc.language, "ko") { return PLURAL_OTHER } + // Default English-like rule + if n == 1 { return PLURAL_ONE } + PLURAL_OTHER +} + +fn plural_form_arabic(n: Int) -> String { + if n == 0 { return PLURAL_ZERO } + if n == 1 { return PLURAL_ONE } + if n == 2 { return PLURAL_TWO } + let mod100: Int = n - ((n / 100) * 100) + if mod100 >= 3 { + if mod100 <= 10 { return PLURAL_FEW } + } + if mod100 >= 11 { + if mod100 <= 99 { return PLURAL_MANY } + } + PLURAL_OTHER +} + +fn plural_form_russian(n: Int) -> String { + let mod10: Int = n - ((n / 10) * 10) + let mod100: Int = n - ((n / 100) * 100) + if mod10 == 1 { + if mod100 == 11 { return PLURAL_MANY } + return PLURAL_ONE + } + if mod10 >= 2 { + if mod10 <= 4 { + if mod100 >= 12 { + if mod100 <= 14 { return PLURAL_MANY } + } + return PLURAL_FEW + } + } + PLURAL_MANY +} + +fn plural_form_polish(n: Int) -> String { + if n == 1 { return PLURAL_ONE } + let mod10: Int = n - ((n / 10) * 10) + let mod100: Int = n - ((n / 100) * 100) + if mod10 >= 2 { + if mod10 <= 4 { + if mod100 >= 12 { + if mod100 <= 14 { return PLURAL_MANY } + } + return PLURAL_FEW + } + } + PLURAL_MANY +} + +// ── Translation bundle ────────────────────────────────────────────────────── +// +// Stored as a JSON map: key -> value | { one: "...", other: "..." } +// `bundle_load_toml` parses a TOML file at load time (planned runtime fn). + +fn bundle_new() -> String { + "{}" +} + +fn bundle_insert(bundle: String, key: String, value: String) -> String { + json_set(bundle, key, "\"" + value + "\"") +} + +fn bundle_insert_plural(bundle: String, key: String, plural_map_json: String) -> String { + json_set(bundle, key, plural_map_json) +} + +fn bundle_load_toml(path: String) -> String { + let raw: String = fs_read(path) + toml_to_json(raw) +} + +// ── LocaleContext + t/t_plural ────────────────────────────────────────────── + +type LocaleContext { + locale: Locale + bundle: String // JSON + fallback_bundle: String +} + +fn locale_context_new(loc: Locale, bundle: String) -> LocaleContext { + { "locale": loc, "bundle": bundle, "fallback_bundle": "{}" } +} + +fn t(ctx: LocaleContext, key: String) -> String { + let v: String = json_get(ctx.bundle, key) + if str_eq(v, "") { let v = json_get(ctx.fallback_bundle, key) } + if str_eq(v, "") { return key } + v +} + +fn t_plural(ctx: LocaleContext, key: String, n: Int) -> String { + let entry: String = json_get(ctx.bundle, key) + if str_eq(entry, "") { return key } + let form: String = plural_form(ctx.locale, n) + let template: String = json_get(entry, form) + if str_eq(template, "") { let template = json_get(entry, PLURAL_OTHER) } + if str_eq(template, "") { return key } + str_replace(template, "{n}", int_to_str(n)) +} + +// ── Number / currency formatting ───────────────────────────────────────────── + +fn format_integer(loc: Locale, n: Int) -> String { + // Group thousands by the locale's separator. Stub: en uses ',', most EU uses '.'. + let sep: String = "," + if str_eq(loc.language, "es") { let sep = "." } + if str_eq(loc.language, "de") { let sep = "." } + if str_eq(loc.language, "fr") { let sep = " " } + int_with_separator(n, sep) +} + +fn format_number(loc: Locale, n: Int, fraction_digits: Int) -> String { + let dec_sep: String = "." + if str_eq(loc.language, "es") { let dec_sep = "," } + if str_eq(loc.language, "de") { let dec_sep = "," } + if str_eq(loc.language, "fr") { let dec_sep = "," } + format_integer(loc, n) + dec_sep + repeat_str("0", fraction_digits) +} + +fn format_percent(loc: Locale, value_x100: Int) -> String { + let body: String = int_to_str(value_x100 / 100) + "." + + int_to_str(value_x100 - ((value_x100 / 100) * 100)) + if str_eq(loc.language, "fr") { return body + " %" } + body + "%" +} + +fn format_currency(loc: Locale, amount_minor: Int, iso: String) -> String { + // amount_minor is in the smallest unit (cents). Stub formatting only. + let major: Int = amount_minor / 100 + let minor: Int = amount_minor - (major * 100) + let body: String = int_to_str(major) + "." + int_to_str(minor) + if str_eq(iso, "USD") { return "$" + body } + if str_eq(iso, "EUR") { return body + " €" } + if str_eq(iso, "GBP") { return "£" + body } + if str_eq(iso, "JPY") { return "¥" + int_to_str(amount_minor) } + body + " " + iso +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let loc: Locale = locale_en_us() +let bundle: String = bundle_new() +let bundle = bundle_insert(bundle, "profile.follow", "Follow") +let ctx: LocaleContext = locale_context_new(loc, bundle) +println("[el-i18n] " + t(ctx, "profile.follow")) diff --git a/ui/crates/el-i18n/src/plural.rs b/ui/vessels/el-i18n/src/plural.rs similarity index 100% rename from ui/crates/el-i18n/src/plural.rs rename to ui/vessels/el-i18n/src/plural.rs diff --git a/ui/crates/el-i18n/src/t.rs b/ui/vessels/el-i18n/src/t.rs similarity index 100% rename from ui/crates/el-i18n/src/t.rs rename to ui/vessels/el-i18n/src/t.rs diff --git a/ui/crates/el-identity/Cargo.toml b/ui/vessels/el-identity/Cargo.toml similarity index 100% rename from ui/crates/el-identity/Cargo.toml rename to ui/vessels/el-identity/Cargo.toml diff --git a/ui/vessels/el-identity/manifest.el b/ui/vessels/el-identity/manifest.el new file mode 100644 index 0000000..ad261d5 --- /dev/null +++ b/ui/vessels/el-identity/manifest.el @@ -0,0 +1,16 @@ +vessel "el-identity" { + version "0.1.0" + description "Engram-native identity: users, roles, sessions, OAuth PKCE flow" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-auth "0.1" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-identity/src/context.rs b/ui/vessels/el-identity/src/context.rs similarity index 100% rename from ui/crates/el-identity/src/context.rs rename to ui/vessels/el-identity/src/context.rs diff --git a/ui/crates/el-identity/src/engram.rs b/ui/vessels/el-identity/src/engram.rs similarity index 100% rename from ui/crates/el-identity/src/engram.rs rename to ui/vessels/el-identity/src/engram.rs diff --git a/ui/crates/el-identity/src/error.rs b/ui/vessels/el-identity/src/error.rs similarity index 100% rename from ui/crates/el-identity/src/error.rs rename to ui/vessels/el-identity/src/error.rs diff --git a/ui/crates/el-identity/src/guard.rs b/ui/vessels/el-identity/src/guard.rs similarity index 100% rename from ui/crates/el-identity/src/guard.rs rename to ui/vessels/el-identity/src/guard.rs diff --git a/ui/crates/el-identity/src/lib.rs b/ui/vessels/el-identity/src/lib.rs similarity index 100% rename from ui/crates/el-identity/src/lib.rs rename to ui/vessels/el-identity/src/lib.rs diff --git a/ui/vessels/el-identity/src/main.el b/ui/vessels/el-identity/src/main.el new file mode 100644 index 0000000..aafbf40 --- /dev/null +++ b/ui/vessels/el-identity/src/main.el @@ -0,0 +1,271 @@ +// el-identity — Engram-native identity for el-ui. +// +// Identity in el-ui is a graph, not a table. Every entity is a node; every +// relationship is an edge. Authentication is spreading activation from a +// session token through the identity subgraph until it touches a User node. +// +// Edges: +// User ──has_role──▶ Role ──grants──▶ Scope +// User ──has_session──▶ Session ──authenticated_via──▶ OAuthToken + +// ── Edge type constants ────────────────────────────────────────────────────── + +let EDGE_HAS_ROLE: String = "has_role" +let EDGE_HAS_SESSION: String = "has_session" +let EDGE_AUTHENTICATED_VIA: String = "authenticated_via" +let EDGE_GRANTS: String = "grants" + +// ── Node type constants ────────────────────────────────────────────────────── + +let NODE_USER: String = "User" +let NODE_ROLE: String = "Role" +let NODE_SCOPE: String = "Scope" +let NODE_OAUTH_TOKEN: String = "OAuthToken" +let NODE_SESSION: String = "Session" + +// ── User ───────────────────────────────────────────────────────────────────── + +type User { + id: String + email: String + display_name: String + created_at: String +} + +fn user_new(email: String, display_name: String) -> User { + let now: String = time_now_iso() + let id: String = uuid_v4() + { "id": id, "email": email, "display_name": display_name, "created_at": now } +} + +// ── Role / Scope ───────────────────────────────────────────────────────────── + +type Role { + id: String + name: String + permissions: String // JSON-encoded array; struct fields are flat in El today +} + +type Scope { + id: String + name: String + description: String +} + +fn role_new(name: String) -> Role { + { "id": uuid_v4(), "name": name, "permissions": "[]" } +} + +fn role_has_permission(role: Role, perm: String) -> Bool { + str_contains(role.permissions, "\"" + perm + "\"") +} + +fn scope_new(name: String, description: String) -> Scope { + { "id": uuid_v4(), "name": name, "description": description } +} + +// ── Session ────────────────────────────────────────────────────────────────── + +type Session { + id: String + user_id: String + created_at: String + expires_at: String + ip_address: String +} + +fn session_new(user_id: String, ttl_seconds: Int, ip_address: String) -> Session { + let now: String = time_now_iso() + let exp: String = time_add_seconds(now, ttl_seconds) + { "id": uuid_v4(), "user_id": user_id, "created_at": now, "expires_at": exp, "ip_address": ip_address } +} + +fn session_is_expired(session: Session) -> Bool { + time_after(time_now_iso(), session.expires_at) +} + +// ── OAuthToken ─────────────────────────────────────────────────────────────── +// +// Tokens are SHA-256 hashed before storage — the raw token never persists. + +type OAuthToken { + id: String + provider: String + access_token_hash: String + refresh_token_hash: String + expires_at: String + scopes: String // JSON-encoded array +} + +fn token_hash(raw: String) -> String { + sha256_hex(raw) +} + +fn oauth_token_new(provider: String, access_raw: String, refresh_raw: String, expires_at: String, scopes: String) -> OAuthToken { + let access_h: String = token_hash(access_raw) + let refresh_h: String = "" + if !str_eq(refresh_raw, "") { let refresh_h = token_hash(refresh_raw) } + { "id": uuid_v4(), "provider": provider, "access_token_hash": access_h, + "refresh_token_hash": refresh_h, "expires_at": expires_at, "scopes": scopes } +} + +fn oauth_token_is_expired(t: OAuthToken) -> Bool { + time_after(time_now_iso(), t.expires_at) +} + +// ── PKCE (RFC 7636) ────────────────────────────────────────────────────────── + +type PkceChallenge { + verifier: String + challenge: String + method: String // always "S256" +} + +fn pkce_generate() -> PkceChallenge { + let verifier: String = base64url_no_pad(random_bytes(32)) + let chal: String = base64url_no_pad(sha256_bytes(verifier)) + { "verifier": verifier, "challenge": chal, "method": "S256" } +} + +fn pkce_verify(challenge: String, verifier: String) -> Bool { + let computed: String = base64url_no_pad(sha256_bytes(verifier)) + str_eq(computed, challenge) +} + +// ── OAuth providers (Google / GitHub / Apple) ──────────────────────────────── +// +// Stubbed: shape-only. `provider_authorization_url` produces the redirect URL; +// `provider_exchange_code` POSTs to the token endpoint via http_post. + +type OAuthProviderCfg { + name: String + client_id: String + client_secret: String + auth_url: String + token_url: String + default_scopes: String +} + +fn google_provider(client_id: String, client_secret: String) -> OAuthProviderCfg { + { "name": "google", "client_id": client_id, "client_secret": client_secret, + "auth_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "default_scopes": "[\"openid\",\"email\",\"profile\"]" } +} + +fn github_provider(client_id: String, client_secret: String) -> OAuthProviderCfg { + { "name": "github", "client_id": client_id, "client_secret": client_secret, + "auth_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "default_scopes": "[\"read:user\",\"user:email\"]" } +} + +fn apple_provider(client_id: String, client_secret: String) -> OAuthProviderCfg { + { "name": "apple", "client_id": client_id, "client_secret": client_secret, + "auth_url": "https://appleid.apple.com/auth/authorize", + "token_url": "https://appleid.apple.com/auth/token", + "default_scopes": "[\"name\",\"email\"]" } +} + +fn provider_authorization_url(p: OAuthProviderCfg, redirect_uri: String, code_challenge: String, state: String) -> String { + p.auth_url + "?response_type=code" + + "&client_id=" + url_encode(p.client_id) + + "&redirect_uri=" + url_encode(redirect_uri) + + "&scope=" + url_encode(json_array_to_space_list(p.default_scopes)) + + "&state=" + url_encode(state) + + "&code_challenge=" + url_encode(code_challenge) + + "&code_challenge_method=S256" +} + +// ── Engram client interface (graph CRUD) ───────────────────────────────────── +// +// Identity persists to the local Engram graph. These wrap engram_* runtime +// calls (planned). Until that lands, the server-side stub uses a JSON file. + +fn engram_create_node(node_type: String, value_json: String) -> String { + engram_node_create(node_type, value_json) +} + +fn engram_create_edge(from_id: String, to_id: String, edge_type: String) -> Bool { + engram_edge_create(from_id, to_id, edge_type) +} + +fn engram_find_connected(node_id: String, edge_type: String) -> String { + engram_edge_traverse(node_id, edge_type) +} + +// ── OAuth flow coordinator ─────────────────────────────────────────────────── + +type AuthFlowParams { + pkce_verifier: String + pkce_challenge: String + redirect_url: String + state: String +} + +fn begin_auth_flow(provider: OAuthProviderCfg, redirect_uri: String) -> AuthFlowParams { + let pkce: PkceChallenge = pkce_generate() + let state: String = base64url_no_pad(random_bytes(16)) + let url: String = provider_authorization_url(provider, redirect_uri, pkce.challenge, state) + { "pkce_verifier": pkce.verifier, "pkce_challenge": pkce.challenge, "redirect_url": url, "state": state } +} + +fn exchange_code(provider: OAuthProviderCfg, code: String, pkce_verifier: String, redirect_uri: String, session_id: String) -> OAuthToken { + let body: String = "grant_type=authorization_code" + + "&code=" + url_encode(code) + + "&redirect_uri=" + url_encode(redirect_uri) + + "&client_id=" + url_encode(provider.client_id) + + "&client_secret=" + url_encode(provider.client_secret) + + "&code_verifier=" + url_encode(pkce_verifier) + let resp: String = http_post(provider.token_url, body) + let access: String = json_get(resp, "access_token") + let refresh: String = json_get(resp, "refresh_token") + let expires_in: Int = str_to_int(json_get(resp, "expires_in")) + let exp_at: String = time_add_seconds(time_now_iso(), expires_in) + let token: OAuthToken = oauth_token_new(provider.name, access, refresh, exp_at, "[]") + let token_id: String = engram_create_node(NODE_OAUTH_TOKEN, json_encode(token)) + engram_create_edge(session_id, token_id, EDGE_AUTHENTICATED_VIA) + token +} + +// ── Session manager ────────────────────────────────────────────────────────── + +fn session_create(user_id: String, ttl: Int, ip: String) -> Session { + let s: Session = session_new(user_id, ttl, ip) + let session_id: String = engram_create_node(NODE_SESSION, json_encode(s)) + engram_create_edge(user_id, session_id, EDGE_HAS_SESSION) + s +} + +fn session_revoke(session_id: String) -> Bool { + engram_node_delete(session_id) +} + +// ── AuthGuard — applied via @authenticate decorator ───────────────────────── + +fn auth_guard_verify(session_id: String) -> Bool { + let raw: String = engram_node_get(session_id) + if str_eq(raw, "") { return false } + let exp: String = json_get(raw, "expires_at") + !time_after(time_now_iso(), exp) +} + +// ── Identity context (passed through the request lifecycle) ───────────────── + +type IdentityContext { + user_id: String + session_id: String + roles_json: String +} + +fn identity_load(session_id: String) -> IdentityContext { + let session_raw: String = engram_node_get(session_id) + let user_id: String = json_get(session_raw, "user_id") + let roles_raw: String = engram_find_connected(user_id, EDGE_HAS_ROLE) + { "user_id": user_id, "session_id": session_id, "roles_json": roles_raw } +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let user: User = user_new("will@neurontechnologies.ai", "Will Anderson") +println("[el-identity] user " + user.email + " (" + user.id + ")") diff --git a/ui/crates/el-identity/src/nodes.rs b/ui/vessels/el-identity/src/nodes.rs similarity index 100% rename from ui/crates/el-identity/src/nodes.rs rename to ui/vessels/el-identity/src/nodes.rs diff --git a/ui/crates/el-identity/src/oauth.rs b/ui/vessels/el-identity/src/oauth.rs similarity index 100% rename from ui/crates/el-identity/src/oauth.rs rename to ui/vessels/el-identity/src/oauth.rs diff --git a/ui/crates/el-identity/src/provider.rs b/ui/vessels/el-identity/src/provider.rs similarity index 100% rename from ui/crates/el-identity/src/provider.rs rename to ui/vessels/el-identity/src/provider.rs diff --git a/ui/crates/el-identity/src/session.rs b/ui/vessels/el-identity/src/session.rs similarity index 100% rename from ui/crates/el-identity/src/session.rs rename to ui/vessels/el-identity/src/session.rs diff --git a/ui/crates/el-identity/src/tests.rs b/ui/vessels/el-identity/src/tests.rs similarity index 100% rename from ui/crates/el-identity/src/tests.rs rename to ui/vessels/el-identity/src/tests.rs diff --git a/ui/crates/el-layout/Cargo.toml b/ui/vessels/el-layout/Cargo.toml similarity index 100% rename from ui/crates/el-layout/Cargo.toml rename to ui/vessels/el-layout/Cargo.toml diff --git a/ui/vessels/el-layout/manifest.el b/ui/vessels/el-layout/manifest.el new file mode 100644 index 0000000..5412d67 --- /dev/null +++ b/ui/vessels/el-layout/manifest.el @@ -0,0 +1,21 @@ +// el-layout — Responsive layout engine for el-ui. +// +// Responsive by default. VStack and HStack wrap automatically. +// Grid uses auto columns. You don't write breakpoints for basic layouts. + +vessel "el-layout" { + version "0.1.0" + description "Stacks, grids, breakpoints, responsive values, safe-area insets" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-style "0.1" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-layout/src/breakpoint.rs b/ui/vessels/el-layout/src/breakpoint.rs similarity index 100% rename from ui/crates/el-layout/src/breakpoint.rs rename to ui/vessels/el-layout/src/breakpoint.rs diff --git a/ui/crates/el-layout/src/constraints.rs b/ui/vessels/el-layout/src/constraints.rs similarity index 100% rename from ui/crates/el-layout/src/constraints.rs rename to ui/vessels/el-layout/src/constraints.rs diff --git a/ui/crates/el-layout/src/flex.rs b/ui/vessels/el-layout/src/flex.rs similarity index 100% rename from ui/crates/el-layout/src/flex.rs rename to ui/vessels/el-layout/src/flex.rs diff --git a/ui/crates/el-layout/src/grid.rs b/ui/vessels/el-layout/src/grid.rs similarity index 100% rename from ui/crates/el-layout/src/grid.rs rename to ui/vessels/el-layout/src/grid.rs diff --git a/ui/crates/el-layout/src/lib.rs b/ui/vessels/el-layout/src/lib.rs similarity index 100% rename from ui/crates/el-layout/src/lib.rs rename to ui/vessels/el-layout/src/lib.rs diff --git a/ui/vessels/el-layout/src/main.el b/ui/vessels/el-layout/src/main.el new file mode 100644 index 0000000..2132b95 --- /dev/null +++ b/ui/vessels/el-layout/src/main.el @@ -0,0 +1,318 @@ +// el-layout — Responsive layout engine for el-ui. +// +// Primitives: +// VStack, HStack, ZStack — stack layouts (wrap by default) +// GridLayout — responsive grid +// ScrollView — scrollable container +// Responsive — value that changes by breakpoint + +// ── Breakpoints ────────────────────────────────────────────────────────────── + +let BP_XS: String = "xs" // < 640px +let BP_SM: String = "sm" // >= 640px +let BP_MD: String = "md" // >= 768px +let BP_LG: String = "lg" // >= 1024px +let BP_XL: String = "xl" // >= 1280px +let BP_XXL: String = "xxl" // >= 1536px + +let BP_SM_PX: Int = 640 +let BP_MD_PX: Int = 768 +let BP_LG_PX: Int = 1024 +let BP_XL_PX: Int = 1280 +let BP_XXL_PX: Int = 1536 + +fn breakpoint_for_width(width_px: Int) -> String { + if width_px >= BP_XXL_PX { return BP_XXL } + if width_px >= BP_XL_PX { return BP_XL } + if width_px >= BP_LG_PX { return BP_LG } + if width_px >= BP_MD_PX { return BP_MD } + if width_px >= BP_SM_PX { return BP_SM } + BP_XS +} + +// Cascade index — used by Responsive to find the most-specific value <= bp. +fn breakpoint_index(bp: String) -> Int { + if str_eq(bp, "xs") { return 0 } + if str_eq(bp, "sm") { return 1 } + if str_eq(bp, "md") { return 2 } + if str_eq(bp, "lg") { return 3 } + if str_eq(bp, "xl") { return 4 } + if str_eq(bp, "xxl") { return 5 } + 0 +} + +// ── Responsive ──────────────────────────────────────────────────────────── +// +// Stored as a JSON object: { "xs": v0, "md": v1, "lg": v2 } +// resolve(bp) walks down from bp until it finds a defined value. + +fn responsive_fixed(value: String) -> String { + "{\"xs\":" + value + "}" +} + +fn responsive_set(rv: String, bp: String, value: String) -> String { + json_set(rv, bp, value) +} + +fn responsive_resolve(rv: String, bp: String) -> String { + let order: String = "xxl,xl,lg,md,sm,xs" + let target_idx: Int = breakpoint_index(bp) + // Try each breakpoint <= target, most-specific first. + let probe: String = bp + while !str_eq(probe, "") { + let v: String = json_get(rv, probe) + if !str_eq(v, "") { return v } + let idx: Int = breakpoint_index(probe) + if idx == 0 { return "" } + let probe = breakpoint_step_down(probe) + } + "" +} + +fn breakpoint_step_down(bp: String) -> String { + if str_eq(bp, "xxl") { return "xl" } + if str_eq(bp, "xl") { return "lg" } + if str_eq(bp, "lg") { return "md" } + if str_eq(bp, "md") { return "sm" } + if str_eq(bp, "sm") { return "xs" } + "" +} + +// ── Constraints / Size ─────────────────────────────────────────────────────── + +type Size { + width: Int + height: Int +} + +type LayoutConstraints { + min_width: Int + max_width: Int + min_height: Int + max_height: Int +} + +fn constraints_unbounded() -> LayoutConstraints { + { "min_width": 0, "max_width": 999999, "min_height": 0, "max_height": 999999 } +} + +fn constraints_tight(w: Int, h: Int) -> LayoutConstraints { + { "min_width": w, "max_width": w, "min_height": h, "max_height": h } +} + +// ── Flex axes ──────────────────────────────────────────────────────────────── + +let FLEX_ROW: String = "row" +let FLEX_COLUMN: String = "column" + +let MAIN_START: String = "start" +let MAIN_END: String = "end" +let MAIN_CENTER: String = "center" +let MAIN_BETWEEN: String = "space-between" +let MAIN_AROUND: String = "space-around" +let MAIN_EVENLY: String = "space-evenly" + +let CROSS_START: String = "start" +let CROSS_END: String = "end" +let CROSS_CENTER: String = "center" +let CROSS_STRETCH: String = "stretch" +let CROSS_BASELINE: String = "baseline" + +type FlexLayout { + direction: String + main_alignment: String + cross_alignment: String + gap_px: Int + wrap: Bool +} + +fn flex_default() -> FlexLayout { + { "direction": "row", "main_alignment": "start", + "cross_alignment": "stretch", "gap_px": 8, "wrap": true } +} + +// ── Stacks ─────────────────────────────────────────────────────────────────── +// +// VStack / HStack / ZStack are component classes in the JS runtime; the +// El side here exposes their layout descriptor — the bag of values the +// el-ui-compiler emits into JSX/HTML attributes. + +type StackLayout { + direction: String // row | column | depth + main_alignment: String + cross_alignment: String + gap_px: Int + wrap: Bool + spacing_token: String // semantic spacing token (md, lg, ...) +} + +fn vstack(spacing_token: String) -> StackLayout { + { "direction": "column", "main_alignment": "start", + "cross_alignment": "stretch", "gap_px": 0, "wrap": true, + "spacing_token": spacing_token } +} + +fn hstack(spacing_token: String) -> StackLayout { + { "direction": "row", "main_alignment": "start", + "cross_alignment": "center", "gap_px": 0, "wrap": true, + "spacing_token": spacing_token } +} + +fn zstack() -> StackLayout { + { "direction": "depth", "main_alignment": "center", + "cross_alignment": "center", "gap_px": 0, "wrap": false, + "spacing_token": "" } +} + +// ── Grid ───────────────────────────────────────────────────────────────────── + +type GridLayout { + columns: String // "auto" or "1fr 1fr 1fr" etc + rows: String + gap_px: Int + auto_fit_min: Int // for `repeat(auto-fit, minmax(, 1fr))` +} + +fn grid_auto(min_col_px: Int, gap: Int) -> GridLayout { + { "columns": "auto-fit", "rows": "auto", "gap_px": gap, "auto_fit_min": min_col_px } +} + +fn grid_fixed(num_cols: Int, gap: Int) -> GridLayout { + let cols: String = repeat_str("1fr ", num_cols) + { "columns": cols, "rows": "auto", "gap_px": gap, "auto_fit_min": 0 } +} + +fn grid_to_css(g: GridLayout) -> String { + let cols: String = g.columns + if str_eq(cols, "auto-fit") { + let cols = "repeat(auto-fit, minmax(" + int_to_str(g.auto_fit_min) + "px, 1fr))" + } + "display: grid; grid-template-columns: " + cols + "; gap: " + int_to_str(g.gap_px) + "px;" +} + +// ── ScrollView ────────────────────────────────────────────────────────────── + +let SCROLL_X: String = "x" +let SCROLL_Y: String = "y" +let SCROLL_BOTH: String = "both" + +type ScrollView { + axis: String + show_indicator: Bool + bounce: Bool // iOS-style overscroll +} + +fn scroll_view_y() -> ScrollView { + { "axis": "y", "show_indicator": true, "bounce": true } +} + +// ── Platform sizing ────────────────────────────────────────────────────────── + +let PLATFORM_PHONE: String = "phone" +let PLATFORM_TABLET: String = "tablet" +let PLATFORM_DESKTOP: String = "desktop" + +type SafeAreaInsets { + top: Int + right: Int + bottom: Int + left: Int +} + +fn safe_area_zero() -> SafeAreaInsets { + { "top": 0, "right": 0, "bottom": 0, "left": 0 } +} + +fn platform_for_width(width: Int) -> String { + if width < 768 { return "phone" } + if width < 1024 { return "tablet" } + "desktop" +} + +// ── HTML emit ──────────────────────────────────────────────────────────────── +// +// Server-side render: layout descriptor → HTML string. +// Children is a pre-rendered HTML string passed in by the caller. +// class_extra is an optional additional CSS class string (pass "" for none). + +fn stack_direction_to_css(direction: String) -> String { + if str_eq(direction, "column") { return "column" } + if str_eq(direction, "depth") { return "unset" } + "row" +} + +fn stack_align_to_css(align: String) -> String { + if str_eq(align, "center") { return "center" } + if str_eq(align, "end") { return "flex-end" } + if str_eq(align, "baseline") { return "baseline" } + if str_eq(align, "stretch") { return "stretch" } + "flex-start" +} + +fn stack_justify_to_css(align: String) -> String { + if str_eq(align, "center") { return "center" } + if str_eq(align, "end") { return "flex-end" } + if str_eq(align, "space-between") { return "space-between" } + if str_eq(align, "space-around") { return "space-around" } + if str_eq(align, "space-evenly") { return "space-evenly" } + "flex-start" +} + +fn stack_to_html(layout: StackLayout, children: String, class_extra: String) -> String { + let direction: String = stack_direction_to_css(layout.direction) + let justify: String = stack_justify_to_css(layout.main_alignment) + let align: String = stack_align_to_css(layout.cross_alignment) + let gap: String = int_to_str(layout.gap_px) + "px" + let wrap_val: String = if layout.wrap { "wrap" } else { "nowrap" } + let style: String = "display:flex;flex-direction:" + direction + + ";justify-content:" + justify + + ";align-items:" + align + + ";gap:" + gap + + ";flex-wrap:" + wrap_val + let base_class: String = "el-stack el-stack--" + layout.direction + let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra } + "
    " + children + "
    " +} + +fn vstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String { + let layout: StackLayout = { "direction": "column", "main_alignment": "start", + "cross_alignment": "stretch", "gap_px": spacing_px, "wrap": true, "spacing_token": "" } + stack_to_html(layout, children, class_extra) +} + +fn hstack_to_html(spacing_px: Int, children: String, class_extra: String) -> String { + let layout: StackLayout = { "direction": "row", "main_alignment": "start", + "cross_alignment": "center", "gap_px": spacing_px, "wrap": true, "spacing_token": "" } + stack_to_html(layout, children, class_extra) +} + +fn zstack_to_html(children: String, class_extra: String) -> String { + let base_class: String = "el-stack el-stack--depth" + let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra } + "
    " + children + "
    " +} + +fn grid_to_html(layout: GridLayout, children: String, class_extra: String) -> String { + let css: String = grid_to_css(layout) + let base_class: String = "el-grid" + let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra } + "
    " + children + "
    " +} + +fn scroll_to_html(layout: ScrollView, children: String, class_extra: String) -> String { + let overflow: String = if str_eq(layout.axis, "x") { "overflow-x:auto;overflow-y:hidden" } + else if str_eq(layout.axis, "both") { "overflow:auto" } + else { "overflow-x:hidden;overflow-y:auto" } + let base_class: String = "el-scroll" + let cls: String = if str_eq(class_extra, "") { base_class } else { base_class + " " + class_extra } + "
    " + children + "
    " +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let r: String = responsive_fixed("1") +let r = responsive_set(r, "md", "2") +let r = responsive_set(r, "lg", "3") +println("[el-layout] cols at lg = " + responsive_resolve(r, "lg")) +let v: String = vstack_to_html(16, "

    hello

    ", "") +println("[el-layout] vstack = " + v) diff --git a/ui/crates/el-layout/src/platform.rs b/ui/vessels/el-layout/src/platform.rs similarity index 100% rename from ui/crates/el-layout/src/platform.rs rename to ui/vessels/el-layout/src/platform.rs diff --git a/ui/crates/el-layout/src/responsive.rs b/ui/vessels/el-layout/src/responsive.rs similarity index 100% rename from ui/crates/el-layout/src/responsive.rs rename to ui/vessels/el-layout/src/responsive.rs diff --git a/ui/crates/el-layout/src/scroll.rs b/ui/vessels/el-layout/src/scroll.rs similarity index 100% rename from ui/crates/el-layout/src/scroll.rs rename to ui/vessels/el-layout/src/scroll.rs diff --git a/ui/crates/el-layout/src/stack.rs b/ui/vessels/el-layout/src/stack.rs similarity index 100% rename from ui/crates/el-layout/src/stack.rs rename to ui/vessels/el-layout/src/stack.rs diff --git a/ui/crates/el-platform/Cargo.toml b/ui/vessels/el-platform/Cargo.toml similarity index 100% rename from ui/crates/el-platform/Cargo.toml rename to ui/vessels/el-platform/Cargo.toml diff --git a/ui/vessels/el-platform/manifest.el b/ui/vessels/el-platform/manifest.el new file mode 100644 index 0000000..553d9cf --- /dev/null +++ b/ui/vessels/el-platform/manifest.el @@ -0,0 +1,23 @@ +// el-platform — Platform abstraction surface for el-ui. +// +// Wraps the El runtime's filesystem, network, environment, and clock +// primitives behind a stable API so application vessels depend on this +// vessel rather than runtime builtin names directly. +// +// The Rust crate also implements per-target render backends +// (web/server/ios/android/macos/linux/windows). At the El layer the +// target is fixed at compile time — the runtime IS the backend — so no +// `PlatformBackend` polymorphism is exposed here. DOM patching and native +// widget mounting will arrive once el-ui-compiler emits browser/native code. + +vessel "el-platform" { + version "1.0.0" + description "Platform abstraction: env, filesystem, network, clock, UUID" + authors ["Will Anderson "] + edition "2026" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-platform/src/backends/android.rs b/ui/vessels/el-platform/src/backends/android.rs similarity index 100% rename from ui/crates/el-platform/src/backends/android.rs rename to ui/vessels/el-platform/src/backends/android.rs diff --git a/ui/crates/el-platform/src/backends/ios.rs b/ui/vessels/el-platform/src/backends/ios.rs similarity index 100% rename from ui/crates/el-platform/src/backends/ios.rs rename to ui/vessels/el-platform/src/backends/ios.rs diff --git a/ui/crates/el-platform/src/backends/linux.rs b/ui/vessels/el-platform/src/backends/linux.rs similarity index 100% rename from ui/crates/el-platform/src/backends/linux.rs rename to ui/vessels/el-platform/src/backends/linux.rs diff --git a/ui/crates/el-platform/src/backends/macos.rs b/ui/vessels/el-platform/src/backends/macos.rs similarity index 100% rename from ui/crates/el-platform/src/backends/macos.rs rename to ui/vessels/el-platform/src/backends/macos.rs diff --git a/ui/crates/el-platform/src/backends/mod.rs b/ui/vessels/el-platform/src/backends/mod.rs similarity index 100% rename from ui/crates/el-platform/src/backends/mod.rs rename to ui/vessels/el-platform/src/backends/mod.rs diff --git a/ui/crates/el-platform/src/backends/server.rs b/ui/vessels/el-platform/src/backends/server.rs similarity index 100% rename from ui/crates/el-platform/src/backends/server.rs rename to ui/vessels/el-platform/src/backends/server.rs diff --git a/ui/crates/el-platform/src/backends/tests.rs b/ui/vessels/el-platform/src/backends/tests.rs similarity index 100% rename from ui/crates/el-platform/src/backends/tests.rs rename to ui/vessels/el-platform/src/backends/tests.rs diff --git a/ui/crates/el-platform/src/backends/web.rs b/ui/vessels/el-platform/src/backends/web.rs similarity index 100% rename from ui/crates/el-platform/src/backends/web.rs rename to ui/vessels/el-platform/src/backends/web.rs diff --git a/ui/crates/el-platform/src/backends/windows.rs b/ui/vessels/el-platform/src/backends/windows.rs similarity index 100% rename from ui/crates/el-platform/src/backends/windows.rs rename to ui/vessels/el-platform/src/backends/windows.rs diff --git a/ui/crates/el-platform/src/config.rs b/ui/vessels/el-platform/src/config.rs similarity index 100% rename from ui/crates/el-platform/src/config.rs rename to ui/vessels/el-platform/src/config.rs diff --git a/ui/crates/el-platform/src/lib.rs b/ui/vessels/el-platform/src/lib.rs similarity index 100% rename from ui/crates/el-platform/src/lib.rs rename to ui/vessels/el-platform/src/lib.rs diff --git a/ui/vessels/el-platform/src/main.el b/ui/vessels/el-platform/src/main.el new file mode 100644 index 0000000..c2ba821 --- /dev/null +++ b/ui/vessels/el-platform/src/main.el @@ -0,0 +1,152 @@ +// el-platform — Platform abstraction surface for el-ui apps. +// +// The Rust crate defines `PlatformBackend` trait + per-target render backends +// (web/server/ios/android/macos/linux/windows). The El surface is narrower: +// El apps reach the host through the runtime's filesystem, network, and OS +// primitives. This vessel wraps those into a stable, named API so El callers +// don't depend directly on builtin names. +// +// RUNTIME PARITY GAPS: +// - There is no `PlatformBackend` polymorphism at the El layer. The runtime +// IS the backend; target selection happens during compilation/packaging, +// not at runtime. +// - DOM mutation, native widget mounting, event binding — none of those +// have El surfaces yet. Use el-ui-compiler when those land. +// - `fs_list` returns a JSON array string; consumers must parse with the +// forthcoming json_array_get builtin. Until that lands, callers should +// treat the value opaquely. + +// ── Targets ───────────────────────────────────────────────────────────────── + +fn target_web() -> String { "web" } +fn target_server() -> String { "server" } +fn target_ios() -> String { "ios" } +fn target_android() -> String { "android" } +fn target_macos() -> String { "macos" } +fn target_linux() -> String { "linux" } +fn target_windows() -> String { "windows" } + +// The compiled binary's target is fixed at build time. EL_TARGET is set by +// the build harness; absent EL_TARGET, default to "server" (the El runtime +// runs as a host process). +fn platform_target() -> String { + let t: String = env("EL_TARGET") + if str_eq(t, "") { return "server" } + t +} + +fn platform_is_native(t: String) -> Bool { + if str_eq(t, "ios") { return true } + if str_eq(t, "android") { return true } + if str_eq(t, "macos") { return true } + if str_eq(t, "linux") { return true } + if str_eq(t, "windows") { return true } + false +} + +fn platform_supports_ssr(t: String) -> Bool { + str_eq(t, "server") +} + +// ── Errors ────────────────────────────────────────────────────────────────── + +fn err_io() -> String { "platform.io" } +fn err_network() -> String { "platform.network" } +fn err_unsupported() -> String { "platform.unsupported" } +fn err_env_missing() -> String { "platform.env_missing" } + +// ── Environment ───────────────────────────────────────────────────────────── + +fn platform_env(key: String) -> String { + env(key) +} + +fn platform_env_or(key: String, fallback: String) -> String { + let v: String = env(key) + if str_eq(v, "") { return fallback } + v +} + +// Strict variant: returns "" if missing, callers branch. +// (Runtime has no panic() — fail-soft return preserves type.) +fn platform_env_required(key: String) -> String { + let v: String = env(key) + if str_eq(v, "") { + println("[el-platform] ERROR " + err_env_missing() + ":" + key) + return "" + } + v +} + +// ── Filesystem ────────────────────────────────────────────────────────────── + +fn platform_fs_read(path: String) -> String { + fs_read(path) +} + +fn platform_fs_write(path: String, content: String) -> Bool { + fs_write(path, content) + true +} + +// Returns a JSON array of entry names (opaque until json_array_get lands). +fn platform_fs_list(path: String) -> String { + let raw: String = fs_list(path) + if str_eq(raw, "") { return "[]" } + raw +} + +fn platform_fs_exists(path: String) -> Bool { + let raw: String = fs_read(path) + !str_eq(raw, "") +} + +// ── HTTP ──────────────────────────────────────────────────────────────────── + +fn platform_http_get(url: String) -> String { + http_get(url) +} + +fn platform_http_post(url: String, body: String) -> String { + http_post(url, body) +} + +fn platform_http_post_json(url: String, json_body: String) -> String { + http_post_json(url, json_body) +} + +fn platform_http_get_authed(url: String, bearer: String) -> String { + let headers: String = "Authorization: Bearer " + bearer + http_get_with_headers(url, headers) +} + +fn platform_http_post_authed(url: String, body: String, bearer: String) -> String { + let headers: String = "Authorization: Bearer " + bearer + http_post_with_headers(url, body, headers) +} + +// ── Time / OS clock ───────────────────────────────────────────────────────── + +fn platform_now() -> Int { + time_now() +} + +fn platform_sleep_ms(ms: Int) -> Bool { + sleep_ms(ms) + true +} + +// ── Identity (for trace/log correlation) ─────────────────────────────────── + +fn platform_uuid() -> String { + uuid_v4() +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── +// +// elc-bug-workaround: top-level `let` does not scope into the surrounding +// statement substitution in the entry block, so we call platform_target() +// inline at each use site. + +println("[el-platform] target=" + platform_target() + " ssr=" + bool_to_str(platform_supports_ssr(platform_target()))) +println("[el-platform] uuid=" + platform_uuid()) diff --git a/ui/crates/el-platform/src/node.rs b/ui/vessels/el-platform/src/node.rs similarity index 100% rename from ui/crates/el-platform/src/node.rs rename to ui/vessels/el-platform/src/node.rs diff --git a/ui/crates/el-publish/Cargo.toml b/ui/vessels/el-publish/Cargo.toml similarity index 100% rename from ui/crates/el-publish/Cargo.toml rename to ui/vessels/el-publish/Cargo.toml diff --git a/ui/vessels/el-publish/manifest.el b/ui/vessels/el-publish/manifest.el new file mode 100644 index 0000000..1f645e3 --- /dev/null +++ b/ui/vessels/el-publish/manifest.el @@ -0,0 +1,25 @@ +// el-publish — App Store and Play Store publishing pipeline. +// +// One command ships to every platform: +// el publish all platforms +// el publish --apple App Store only +// el publish --google Play Store only +// el publish --beta TestFlight + Play internal track + +vessel "el-publish" { + version "0.1.0" + description "Apple App Store + Google Play publishing automation" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-config "0.1" + el-secrets "0.1" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-publish/src/apple.rs b/ui/vessels/el-publish/src/apple.rs similarity index 100% rename from ui/crates/el-publish/src/apple.rs rename to ui/vessels/el-publish/src/apple.rs diff --git a/ui/crates/el-publish/src/cert.rs b/ui/vessels/el-publish/src/cert.rs similarity index 100% rename from ui/crates/el-publish/src/cert.rs rename to ui/vessels/el-publish/src/cert.rs diff --git a/ui/crates/el-publish/src/config.rs b/ui/vessels/el-publish/src/config.rs similarity index 100% rename from ui/crates/el-publish/src/config.rs rename to ui/vessels/el-publish/src/config.rs diff --git a/ui/crates/el-publish/src/google.rs b/ui/vessels/el-publish/src/google.rs similarity index 100% rename from ui/crates/el-publish/src/google.rs rename to ui/vessels/el-publish/src/google.rs diff --git a/ui/crates/el-publish/src/lib.rs b/ui/vessels/el-publish/src/lib.rs similarity index 100% rename from ui/crates/el-publish/src/lib.rs rename to ui/vessels/el-publish/src/lib.rs diff --git a/ui/vessels/el-publish/src/main.el b/ui/vessels/el-publish/src/main.el new file mode 100644 index 0000000..6b97f43 --- /dev/null +++ b/ui/vessels/el-publish/src/main.el @@ -0,0 +1,243 @@ +// el-publish — App Store + Play Store publishing pipeline. +// +// Two providers (Apple, Google), one publish flow: +// 1. Validate config + certs +// 2. Build artifact (delegated to el-ui-compiler / xcodebuild / gradle) +// 3. Capture metadata + screenshots +// 4. Upload to App Store Connect / Play Developer API +// 5. Monitor rollout + +// ── Errors ─────────────────────────────────────────────────────────────────── + +let PUB_ERR_CONFIG: String = "publish.config" +let PUB_ERR_BUILD: String = "publish.build" +let PUB_ERR_UPLOAD: String = "publish.upload" +let PUB_ERR_CERT: String = "publish.certificate" +let PUB_ERR_METADATA: String = "publish.metadata" +let PUB_ERR_API: String = "publish.api" +let PUB_ERR_IO: String = "publish.io" + +// ── Tracks ─────────────────────────────────────────────────────────────────── + +let TRACK_PRODUCTION: String = "production" +let TRACK_BETA: String = "beta" // TestFlight on Apple; "beta" on Google +let TRACK_INTERNAL: String = "internal" +let TRACK_ALPHA: String = "alpha" + +// ── Apple config ───────────────────────────────────────────────────────────── + +type AppleConfig { + account: String // Apple ID + bundle_id: String // ai.neurontechnologies.myapp + team_id: String + api_key_id: String // App Store Connect API key + api_issuer: String +} + +// ── Google config ──────────────────────────────────────────────────────────── + +type GoogleConfig { + package: String // ai.neurontechnologies.myapp + track: String // production | beta | internal | alpha + service_account_json_ref: String // secret ref to service-account JSON +} + +// ── Rollout (staged) ───────────────────────────────────────────────────────── + +type RolloutConfig { + initial_percent: Int // 0..100 + target_percent: Int + bake_hours: Int // hours between rollout stages +} + +fn rollout_default() -> RolloutConfig { + { "initial_percent": 5, "target_percent": 100, "bake_hours": 24 } +} + +// ── Top-level publish config ──────────────────────────────────────────────── + +type PublishConfig { + version: String + build_number: Int + apple_json: String // empty if not configured + google_json: String + rollout_json: String // RolloutConfig as JSON +} + +fn publish_config_new(version: String, build_number: Int) -> PublishConfig { + { "version": version, "build_number": build_number, + "apple_json": "", "google_json": "", "rollout_json": json_encode(rollout_default()) } +} + +// ── Outcome ────────────────────────────────────────────────────────────────── + +type PublishOutcome { + platform: String + version: String + build_number: Int + track: String + rollout_percent: Int + submission_id: String +} + +fn outcome_new(platform: String, cfg: PublishConfig, track: String) -> PublishOutcome { + let pct: Int = 100 + if !str_eq(cfg.rollout_json, "") { + let pct = str_to_int(json_get(cfg.rollout_json, "initial_percent")) + } + { "platform": platform, "version": cfg.version, "build_number": cfg.build_number, + "track": track, "rollout_percent": pct, "submission_id": "" } +} + +// ── Cert store ────────────────────────────────────────────────────────────── +// +// Tracks .p12 / .mobileprovision (Apple) and signing keystores (Google). + +type CertInfo { + kind: String // "apple_p12" | "apple_provisioning" | "google_keystore" + path: String + expires_at: String // ISO 8601 + fingerprint: String +} + +fn cert_is_valid(c: CertInfo) -> Bool { + !time_after(time_now_iso(), c.expires_at) +} + +fn cert_store_load(dir: String) -> String { + // Returns JSON array of CertInfo loaded from `dir`. + let files: String = fs_list(dir) + let out: String = "[]" + let n: Int = json_array_len(files) + let i: Int = 0 + while i < n { + let path: String = json_array_get(files, i) + let info: CertInfo = cert_inspect(path) + let out = json_array_push(out, json_encode(info)) + let i = i + 1 + } + out +} + +fn cert_inspect(path: String) -> CertInfo { + let kind: String = "apple_p12" + if str_ends_with(path, ".mobileprovision") { let kind = "apple_provisioning" } + if str_ends_with(path, ".jks") { let kind = "google_keystore" } + if str_ends_with(path, ".keystore") { let kind = "google_keystore" } + let exp: String = exec_capture("openssl", "x509 -enddate -noout -in " + path) + { "kind": kind, "path": path, "expires_at": exp, "fingerprint": "" } +} + +// ── Metadata ──────────────────────────────────────────────────────────────── + +type StoreMetadata { + title: String + subtitle: String + description: String + keywords: String // comma-separated + privacy_url: String + support_url: String + locale: String // e.g. "en-US" +} + +fn metadata_load(path: String, locale: String) -> StoreMetadata { + let raw: String = fs_read(path + "/" + locale + ".toml") + let json: String = toml_to_json(raw) + { "title": json_get(json, "title"), + "subtitle": json_get(json, "subtitle"), + "description": json_get(json, "description"), + "keywords": json_get(json, "keywords"), + "privacy_url": json_get(json, "privacy_url"), + "support_url": json_get(json, "support_url"), + "locale": locale } +} + +// ── Screenshots ───────────────────────────────────────────────────────────── + +type ScreenshotTarget { + platform: String // "apple" | "google" + device_class: String // "iphone-6.7" | "ipad-12.9" | "phone" | "tablet" | "tv" + locale: String + expected_resolution: String // "1290x2796" +} + +fn target_iphone_6_7(locale: String) -> ScreenshotTarget { + { "platform": "apple", "device_class": "iphone-6.7", + "locale": locale, "expected_resolution": "1290x2796" } +} + +fn target_phone_google(locale: String) -> ScreenshotTarget { + { "platform": "google", "device_class": "phone", + "locale": locale, "expected_resolution": "1080x1920" } +} + +fn screenshot_capture(target: ScreenshotTarget, simulator_id: String, output_dir: String) -> String { + // Drives simulator/emulator to capture at expected resolution. + let path: String = output_dir + "/" + target.platform + "_" + target.device_class + + "_" + target.locale + ".png" + exec_capture("xcrun", "simctl io " + simulator_id + " screenshot " + path) + path +} + +// ── Apple publisher ───────────────────────────────────────────────────────── + +fn apple_publish(cfg: PublishConfig, apple: AppleConfig, ipa_path: String, track: String) -> PublishOutcome { + let api_key: String = secret_lookup("apple.api_key") + let resp: String = exec_capture("xcrun", "altool --upload-app -f " + ipa_path + + " --type ios --apiKey " + apple.api_key_id + + " --apiIssuer " + apple.api_issuer) + let outcome: PublishOutcome = outcome_new("apple", cfg, track) + let submission: String = json_get(resp, "submission_id") + { "platform": outcome.platform, "version": outcome.version, + "build_number": outcome.build_number, "track": outcome.track, + "rollout_percent": outcome.rollout_percent, "submission_id": submission } +} + +// ── Google publisher ──────────────────────────────────────────────────────── + +fn google_publish(cfg: PublishConfig, google: GoogleConfig, aab_path: String) -> PublishOutcome { + let sa_json: String = secret_lookup(google.service_account_json_ref) + let token: String = google_oauth_token(sa_json) + let edit: String = google_play_edit_create(google.package, token) + let upload: String = google_play_upload_aab(google.package, edit, aab_path, token) + let assigned: String = google_play_track_assign(google.package, edit, google.track, + cfg.version, cfg.build_number, token) + google_play_edit_commit(google.package, edit, token) + let outcome: PublishOutcome = outcome_new("google", cfg, google.track) + { "platform": outcome.platform, "version": outcome.version, + "build_number": outcome.build_number, "track": outcome.track, + "rollout_percent": outcome.rollout_percent, "submission_id": json_get(upload, "id") } +} + +// ── Rollout monitor ───────────────────────────────────────────────────────── + +fn rollout_monitor(outcome: PublishOutcome, cfg: RolloutConfig) -> Bool { + // Polls store status, advances rollout percentage in stages. + let current: Int = outcome.rollout_percent + while current < cfg.target_percent { + sleep_seconds(cfg.bake_hours * 3600) + let crash_rate: Int = fetch_crash_rate(outcome.platform, outcome.version) + if crash_rate > 100 { // 1% crash threshold (per 10000) + println("[el-publish] rollout halted: crash rate too high") + return false + } + let next: Int = current + 25 + if next > cfg.target_percent { let next = cfg.target_percent } + rollout_advance(outcome, next) + let current = next + } + true +} + +fn rollout_advance(outcome: PublishOutcome, percent: Int) -> Bool { + println("[el-publish] " + outcome.platform + " rollout -> " + int_to_str(percent) + "%") + true +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let cfg: PublishConfig = publish_config_new("1.0.0", 42) +let apple: AppleConfig = { "account": "will@neurontechnologies.ai", + "bundle_id": "ai.neurontechnologies.myapp", "team_id": "ABC123", + "api_key_id": "KEY", "api_issuer": "ISS" } +println("[el-publish] " + cfg.version + " (" + int_to_str(cfg.build_number) + ") for " + apple.bundle_id) diff --git a/ui/crates/el-publish/src/metadata.rs b/ui/vessels/el-publish/src/metadata.rs similarity index 100% rename from ui/crates/el-publish/src/metadata.rs rename to ui/vessels/el-publish/src/metadata.rs diff --git a/ui/crates/el-publish/src/rollout.rs b/ui/vessels/el-publish/src/rollout.rs similarity index 100% rename from ui/crates/el-publish/src/rollout.rs rename to ui/vessels/el-publish/src/rollout.rs diff --git a/ui/crates/el-publish/src/screenshot.rs b/ui/vessels/el-publish/src/screenshot.rs similarity index 100% rename from ui/crates/el-publish/src/screenshot.rs rename to ui/vessels/el-publish/src/screenshot.rs diff --git a/ui/crates/el-publish/src/tests.rs b/ui/vessels/el-publish/src/tests.rs similarity index 100% rename from ui/crates/el-publish/src/tests.rs rename to ui/vessels/el-publish/src/tests.rs diff --git a/ui/crates/el-secrets/Cargo.toml b/ui/vessels/el-secrets/Cargo.toml similarity index 100% rename from ui/crates/el-secrets/Cargo.toml rename to ui/vessels/el-secrets/Cargo.toml diff --git a/ui/vessels/el-secrets/manifest.el b/ui/vessels/el-secrets/manifest.el new file mode 100644 index 0000000..dbc342e --- /dev/null +++ b/ui/vessels/el-secrets/manifest.el @@ -0,0 +1,20 @@ +// el-secrets — Secrets management for el-ui applications. +// +// Secrets are NEVER in code. Wrapped in a `Secret` type that displays as +// `[REDACTED]` in all logs and JSON output. Explicit `.expose()` to read. + +vessel "el-secrets" { + version "0.1.0" + description "Resolver chain: env vars, Vault, AWS Secrets Manager" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-secrets/src/error.rs b/ui/vessels/el-secrets/src/error.rs similarity index 100% rename from ui/crates/el-secrets/src/error.rs rename to ui/vessels/el-secrets/src/error.rs diff --git a/ui/crates/el-secrets/src/lib.rs b/ui/vessels/el-secrets/src/lib.rs similarity index 100% rename from ui/crates/el-secrets/src/lib.rs rename to ui/vessels/el-secrets/src/lib.rs diff --git a/ui/vessels/el-secrets/src/main.el b/ui/vessels/el-secrets/src/main.el new file mode 100644 index 0000000..9608d70 --- /dev/null +++ b/ui/vessels/el-secrets/src/main.el @@ -0,0 +1,195 @@ +// el-secrets — Secrets management for el-ui. +// +// A Secret is a value with a redacted display contract. Source plugins +// fetch by key from env, Vault, AWS Secrets Manager, or in-memory. +// `SecretsResolver` composes a chain and resolves required keys at startup. + +// ── Errors ─────────────────────────────────────────────────────────────────── + +let SECRET_ERR_MISSING: String = "secret.missing" +let SECRET_ERR_SOURCE: String = "secret.source_error" +let SECRET_ERR_PERMISSION: String = "secret.permission_denied" + +// ── Source kinds ───────────────────────────────────────────────────────────── + +let SRC_ENV: String = "env" +let SRC_VAULT: String = "vault" +let SRC_AWS_SECRETS: String = "aws_secrets" +let SRC_IN_MEMORY: String = "in_memory" + +// ── Source descriptor ─────────────────────────────────────────────────────── + +type SecretsSource { + kind: String // env | vault | aws_secrets | in_memory + config_json: String // source-specific (e.g. {"prefix":"EL_SECRET_"} or {"path":"secret/data/app"}) +} + +fn source_env(prefix: String) -> SecretsSource { + { "kind": "env", "config_json": "{\"prefix\":\"" + prefix + "\"}" } +} + +fn source_vault(addr: String, token: String, mount: String) -> SecretsSource { + let cfg: String = "{\"addr\":\"" + addr + "\",\"token\":\"" + token + + "\",\"mount\":\"" + mount + "\"}" + { "kind": "vault", "config_json": cfg } +} + +fn source_aws(region: String) -> SecretsSource { + { "kind": "aws_secrets", "config_json": "{\"region\":\"" + region + "\"}" } +} + +fn source_in_memory() -> SecretsSource { + { "kind": "in_memory", "config_json": "{\"map\":{}}" } +} + +// ── Source lookup ─────────────────────────────────────────────────────────── +// +// Each source kind has its own fetch fn. `source_get` dispatches by kind. + +fn source_get(src: SecretsSource, key: String) -> String { + if str_eq(src.kind, "env") { + let prefix: String = json_get(src.config_json, "prefix") + return env(prefix + str_to_upper(str_replace(key, ".", "_"))) + } + if str_eq(src.kind, "vault") { + let addr: String = json_get(src.config_json, "addr") + let token: String = json_get(src.config_json, "token") + let mount: String = json_get(src.config_json, "mount") + return vault_kv_get_at(addr, token, mount, key) + } + if str_eq(src.kind, "aws_secrets") { + let region: String = json_get(src.config_json, "region") + return aws_secrets_get(region, key) + } + if str_eq(src.kind, "in_memory") { + let map: String = json_get(src.config_json, "map") + return json_get(map, key) + } + "" +} + +fn in_memory_insert(src: SecretsSource, key: String, value: String) -> SecretsSource { + let map: String = json_get(src.config_json, "map") + let map = json_set(map, key, "\"" + value + "\"") + let cfg = json_set(src.config_json, "map", map) + { "kind": src.kind, "config_json": cfg } +} + +// ── Secret — redacted wrapper ──────────────────────────────────────────── +// +// A Secret stores its raw value behind a tag. `secret_display` always +// returns `[REDACTED]`. `secret_expose` returns the actual string. +// Serializers should call `secret_display` by default. + +type Secret { + tag: String // marker: "secret" + raw: String // the actual value — never log this directly +} + +fn secret_new(value: String) -> Secret { + { "tag": "secret", "raw": value } +} + +fn secret_display(s: Secret) -> String { + "[REDACTED]" +} + +fn secret_expose(s: Secret) -> String { + s.raw +} + +// Disposable read-once guard — like Rust's `SecretGuard`. Consumes the secret +// once and zeroes it. Useful at boundaries: read JWT signing key once, hand to +// the signing routine, never readable again. +type SecretGuard { + consumed: Bool + raw: String +} + +fn guard_new(s: Secret) -> SecretGuard { + { "consumed": false, "raw": s.raw } +} + +fn guard_take(g: SecretGuard) -> String { + if g.consumed { return "" } + g.raw +} + +// ── SecretsResolver — builder ─────────────────────────────────────────────── +// +// Sources are tried in order. First non-empty hit wins. +// `require(key)` marks a key as required at startup; `resolve()` fails if any +// required key is unresolved. + +type SecretsResolver { + sources_json: String // JSON array of SecretsSource + required_json: String // JSON array of required keys +} + +fn resolver_new() -> SecretsResolver { + { "sources_json": "[]", "required_json": "[]" } +} + +fn resolver_source(r: SecretsResolver, src: SecretsSource) -> SecretsResolver { + { "sources_json": json_array_push(r.sources_json, json_encode(src)), + "required_json": r.required_json } +} + +fn resolver_require(r: SecretsResolver, key: String) -> SecretsResolver { + { "sources_json": r.sources_json, + "required_json": json_array_push(r.required_json, "\"" + key + "\"") } +} + +// Resolved bag. Keys -> Secret. Failure if any required key is missing. +type ResolvedSecrets { + map_json: String // JSON: key -> raw value + errors: String // JSON array of missing required keys +} + +fn resolver_resolve(r: SecretsResolver) -> ResolvedSecrets { + let resolved: String = "{}" + let errors: String = "[]" + let n: Int = json_array_len(r.required_json) + let i: Int = 0 + while i < n { + let key: String = json_array_get(r.required_json, i) + let value: String = lookup_chain(r.sources_json, key) + if str_eq(value, "") { + let errors = json_array_push(errors, "\"" + key + "\"") + } + if !str_eq(value, "") { + let resolved = json_set(resolved, key, "\"" + value + "\"") + } + let i = i + 1 + } + { "map_json": resolved, "errors": errors } +} + +fn lookup_chain(sources_json: String, key: String) -> String { + let n: Int = json_array_len(sources_json) + let i: Int = 0 + while i < n { + let src_json: String = json_array_get(sources_json, i) + let src: SecretsSource = { "kind": json_get(src_json, "kind"), + "config_json": json_get(src_json, "config_json") } + let v: String = source_get(src, key) + if !str_eq(v, "") { return v } + let i = i + 1 + } + "" +} + +fn resolved_require(r: ResolvedSecrets, key: String) -> Secret { + let raw: String = json_get(r.map_json, key) + secret_new(raw) +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let src: SecretsSource = source_in_memory() +let src = in_memory_insert(src, "jwt.key", "test-key-for-testing-only") +let resolver: SecretsResolver = resolver_source(resolver_new(), src) +let resolver = resolver_require(resolver, "jwt.key") +let resolved: ResolvedSecrets = resolver_resolve(resolver) +let jwt: Secret = resolved_require(resolved, "jwt.key") +println("[el-secrets] jwt.key = " + secret_display(jwt)) diff --git a/ui/crates/el-secrets/src/resolver.rs b/ui/vessels/el-secrets/src/resolver.rs similarity index 100% rename from ui/crates/el-secrets/src/resolver.rs rename to ui/vessels/el-secrets/src/resolver.rs diff --git a/ui/crates/el-secrets/src/secret.rs b/ui/vessels/el-secrets/src/secret.rs similarity index 100% rename from ui/crates/el-secrets/src/secret.rs rename to ui/vessels/el-secrets/src/secret.rs diff --git a/ui/crates/el-secrets/src/source.rs b/ui/vessels/el-secrets/src/source.rs similarity index 100% rename from ui/crates/el-secrets/src/source.rs rename to ui/vessels/el-secrets/src/source.rs diff --git a/ui/crates/el-services/Cargo.toml b/ui/vessels/el-services/Cargo.toml similarity index 100% rename from ui/crates/el-services/Cargo.toml rename to ui/vessels/el-services/Cargo.toml diff --git a/ui/vessels/el-services/manifest.el b/ui/vessels/el-services/manifest.el new file mode 100644 index 0000000..ad60c9f --- /dev/null +++ b/ui/vessels/el-services/manifest.el @@ -0,0 +1,20 @@ +// el-services — Service bindings for el-ui. +// +// Write once. Bind to any protocol. Change the binding in `manifest.el`. + +vessel "el-services" { + version "0.1.0" + description "Pluggable service bindings: REST, gRPC, WebSocket, direct" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" + el-config "0.1" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-services/src/binding/direct.rs b/ui/vessels/el-services/src/binding/direct.rs similarity index 100% rename from ui/crates/el-services/src/binding/direct.rs rename to ui/vessels/el-services/src/binding/direct.rs diff --git a/ui/crates/el-services/src/binding/grpc.rs b/ui/vessels/el-services/src/binding/grpc.rs similarity index 100% rename from ui/crates/el-services/src/binding/grpc.rs rename to ui/vessels/el-services/src/binding/grpc.rs diff --git a/ui/crates/el-services/src/binding/mod.rs b/ui/vessels/el-services/src/binding/mod.rs similarity index 100% rename from ui/crates/el-services/src/binding/mod.rs rename to ui/vessels/el-services/src/binding/mod.rs diff --git a/ui/crates/el-services/src/binding/rest.rs b/ui/vessels/el-services/src/binding/rest.rs similarity index 100% rename from ui/crates/el-services/src/binding/rest.rs rename to ui/vessels/el-services/src/binding/rest.rs diff --git a/ui/crates/el-services/src/binding/websocket.rs b/ui/vessels/el-services/src/binding/websocket.rs similarity index 100% rename from ui/crates/el-services/src/binding/websocket.rs rename to ui/vessels/el-services/src/binding/websocket.rs diff --git a/ui/crates/el-services/src/config.rs b/ui/vessels/el-services/src/config.rs similarity index 100% rename from ui/crates/el-services/src/config.rs rename to ui/vessels/el-services/src/config.rs diff --git a/ui/crates/el-services/src/lib.rs b/ui/vessels/el-services/src/lib.rs similarity index 100% rename from ui/crates/el-services/src/lib.rs rename to ui/vessels/el-services/src/lib.rs diff --git a/ui/vessels/el-services/src/main.el b/ui/vessels/el-services/src/main.el new file mode 100644 index 0000000..1f2d975 --- /dev/null +++ b/ui/vessels/el-services/src/main.el @@ -0,0 +1,197 @@ +// el-services — Pluggable service bindings. +// +// A "service" is a logical interface. A "binding" is the wire protocol that +// fulfills it. The binding kind is set in manifest.el; the service code does +// not change when the binding changes. +// +// binding = "rest" -> RestBinding +// binding = "grpc" -> GrpcBinding (planned) +// binding = "websocket" -> WebSocketBinding +// binding = "direct" -> in-process function call (for testing) + +// ── Binding kinds ──────────────────────────────────────────────────────────── + +let BIND_REST: String = "rest" +let BIND_GRPC: String = "grpc" +let BIND_WEBSOCKET: String = "websocket" +let BIND_DIRECT: String = "direct" + +// ── Errors ─────────────────────────────────────────────────────────────────── + +let SVC_ERR_NOT_FOUND: String = "service.not_found" +let SVC_ERR_BINDING: String = "service.binding" +let SVC_ERR_TRANSPORT: String = "service.transport" +let SVC_ERR_AUTH: String = "service.auth" +let SVC_ERR_METHOD_NOT_FOUND: String = "service.method_not_found" + +// ── Service config ─────────────────────────────────────────────────────────── + +let AUTH_NONE: String = "none" +let AUTH_BEARER: String = "bearer" +let AUTH_BASIC: String = "basic" +let AUTH_HMAC: String = "hmac" + +type AuthConfig { + kind: String // none | bearer | basic | hmac + secret_ref: String // e.g. "env:API_TOKEN" or "vault:path" +} + +type ServiceConfig { + name: String + binding: String + base_url: String + auth: AuthConfig + timeout_ms: Int +} + +fn service_config_rest(name: String, base_url: String, auth_secret_ref: String) -> ServiceConfig { + let auth: AuthConfig = { "kind": "bearer", "secret_ref": auth_secret_ref } + { "name": name, "binding": "rest", "base_url": base_url, "auth": auth, "timeout_ms": 5000 } +} + +// ── Service request/response ──────────────────────────────────────────────── + +type ServiceRequest { + method: String // logical method name (e.g. "list_users") + args: String // JSON + metadata: String // JSON (auth tokens, trace ids) +} + +type ServiceResponse { + status: Int // 0 = ok, non-zero = error code + body: String // JSON + error: String // empty if ok +} + +fn response_ok(body: String) -> ServiceResponse { + { "status": 0, "body": body, "error": "" } +} + +fn response_err(code: Int, error: String) -> ServiceResponse { + { "status": code, "body": "", "error": error } +} + +// ── REST binding ──────────────────────────────────────────────────────────── +// +// Method name -> (HTTP verb, path template). The compiler emits this table +// from the service interface declaration. + +fn rest_invoke(cfg: ServiceConfig, http_method: String, path: String, req: ServiceRequest) -> ServiceResponse { + let url: String = cfg.base_url + path + let headers: String = build_auth_header(cfg.auth) + let body: String = req.args + let resp: String = "" + if str_eq(http_method, "GET") { + let resp = http_get_with_headers(url, headers) + } + if str_eq(http_method, "POST") { + let resp = http_post_with_headers(url, body, headers) + } + if str_eq(http_method, "PUT") { + let resp = http_put_with_headers(url, body, headers) + } + if str_eq(http_method, "DELETE") { + let resp = http_delete_with_headers(url, headers) + } + if str_eq(resp, "") { return response_err(599, SVC_ERR_TRANSPORT) } + response_ok(resp) +} + +fn build_auth_header(auth: AuthConfig) -> String { + if str_eq(auth.kind, "bearer") { + let secret: String = resolve_secret_ref(auth.secret_ref) + return "Authorization: Bearer " + secret + } + if str_eq(auth.kind, "basic") { + let creds: String = resolve_secret_ref(auth.secret_ref) + return "Authorization: Basic " + base64_encode(creds) + } + "" +} + +fn resolve_secret_ref(ref: String) -> String { + if str_starts_with(ref, "env:") { + return env(str_slice(ref, 4, str_len(ref))) + } + if str_starts_with(ref, "vault:") { + return vault_kv_get(str_slice(ref, 6, str_len(ref))) + } + "" +} + +// ── gRPC binding (planned) ────────────────────────────────────────────────── +// +// Requires generated stubs from a .proto. The shape is identical to REST: +// a method dispatcher that converts logical method calls to wire calls. + +fn grpc_invoke(cfg: ServiceConfig, method: String, req: ServiceRequest) -> ServiceResponse { + response_err(501, "grpc binding not yet implemented") +} + +// ── WebSocket binding ─────────────────────────────────────────────────────── + +fn ws_invoke(cfg: ServiceConfig, method: String, req: ServiceRequest) -> ServiceResponse { + let frame: String = "{\"method\":\"" + method + "\",\"args\":" + req.args + "}" + let reply: String = ws_send_recv(cfg.base_url, frame) + if str_eq(reply, "") { return response_err(599, SVC_ERR_TRANSPORT) } + response_ok(reply) +} + +// ── Direct binding (in-process — used in tests / single-process apps) ────── + +fn direct_invoke(handler_fn_name: String, req: ServiceRequest) -> ServiceResponse { + let body: String = call_dynamic2(handler_fn_name, req.method, req.args) + response_ok(body) +} + +// ── ServiceRegistry ───────────────────────────────────────────────────────── +// +// Maps service name -> ServiceConfig. Methods are looked up in a separate +// per-service method table (logical method -> (verb, path) for REST, etc). + +fn registry_new() -> String { + "{}" +} + +fn registry_register(reg: String, cfg: ServiceConfig) -> String { + json_set(reg, cfg.name, json_encode(cfg)) +} + +fn registry_lookup(reg: String, name: String) -> String { + json_get(reg, name) +} + +// ── ServiceProxy — the user-facing call site ──────────────────────────────── +// +// A ServiceProxy is a (registry, service_name, method_table) trio. The +// el-ui-compiler emits one proxy class per service interface. + +type ServiceProxy { + service_name: String + config: ServiceConfig + method_table: String // JSON: method -> { http_verb, path } +} + +fn proxy_invoke(proxy: ServiceProxy, method: String, args_json: String) -> ServiceResponse { + let req: ServiceRequest = { "method": method, "args": args_json, "metadata": "{}" } + let cfg: ServiceConfig = proxy.config + if str_eq(cfg.binding, "rest") { + let m: String = json_get(proxy.method_table, method) + if str_eq(m, "") { return response_err(404, SVC_ERR_METHOD_NOT_FOUND) } + let verb: String = json_get(m, "verb") + let path: String = json_get(m, "path") + return rest_invoke(cfg, verb, path, req) + } + if str_eq(cfg.binding, "grpc") { + return grpc_invoke(cfg, method, req) + } + if str_eq(cfg.binding, "websocket") { + return ws_invoke(cfg, method, req) + } + response_err(400, SVC_ERR_BINDING) +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let cfg: ServiceConfig = service_config_rest("UserService", "https://api.example.com", "env:API_TOKEN") +println("[el-services] registered " + cfg.name + " @ " + cfg.base_url) diff --git a/ui/crates/el-services/src/proxy.rs b/ui/vessels/el-services/src/proxy.rs similarity index 100% rename from ui/crates/el-services/src/proxy.rs rename to ui/vessels/el-services/src/proxy.rs diff --git a/ui/crates/el-services/src/registry.rs b/ui/vessels/el-services/src/registry.rs similarity index 100% rename from ui/crates/el-services/src/registry.rs rename to ui/vessels/el-services/src/registry.rs diff --git a/ui/crates/el-services/src/tests.rs b/ui/vessels/el-services/src/tests.rs similarity index 100% rename from ui/crates/el-services/src/tests.rs rename to ui/vessels/el-services/src/tests.rs diff --git a/ui/crates/el-style/Cargo.toml b/ui/vessels/el-style/Cargo.toml similarity index 100% rename from ui/crates/el-style/Cargo.toml rename to ui/vessels/el-style/Cargo.toml diff --git a/ui/vessels/el-style/manifest.el b/ui/vessels/el-style/manifest.el new file mode 100644 index 0000000..2cf2122 --- /dev/null +++ b/ui/vessels/el-style/manifest.el @@ -0,0 +1,21 @@ +// el-style — The el-ui design system. +// +// Platform-agnostic, theme-driven style representation. Every value is a +// semantic token — the theme maps tokens to actual colors, sizes, and shadows. +// Components never hardcode visual values. + +vessel "el-style" { + version "0.1.0" + description "Design tokens, themes, and the StyleModifier fluent API" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-style/src/color.rs b/ui/vessels/el-style/src/color.rs similarity index 100% rename from ui/crates/el-style/src/color.rs rename to ui/vessels/el-style/src/color.rs diff --git a/ui/crates/el-style/src/lib.rs b/ui/vessels/el-style/src/lib.rs similarity index 100% rename from ui/crates/el-style/src/lib.rs rename to ui/vessels/el-style/src/lib.rs diff --git a/ui/vessels/el-style/src/main.el b/ui/vessels/el-style/src/main.el new file mode 100644 index 0000000..e114885 --- /dev/null +++ b/ui/vessels/el-style/src/main.el @@ -0,0 +1,287 @@ +// el-style — The el-ui design system. +// +// Hierarchy: +// 1. Theme — single source of truth, set at experience root +// 2. Semantic tokens — Color::Primary, Spacing::Lg, Radius::Md, ... +// 3. StyleModifier — fluent builder applied to any component +// 4. StyleSheet — named rule sets, applied with .apply("card") +// +// Resolution flow: +// component declares semantic token → theme resolves to RGBA / px → +// compiler emits CSS-in-JS for the browser target / NSColor for macOS / ... + +// ── Color tokens ───────────────────────────────────────────────────────────── + +let COLOR_PRIMARY: String = "primary" +let COLOR_ON_PRIMARY: String = "on_primary" +let COLOR_PRIMARY_CONTAINER: String = "primary_container" +let COLOR_SECONDARY: String = "secondary" +let COLOR_ON_SECONDARY: String = "on_secondary" +let COLOR_BACKGROUND: String = "background" +let COLOR_ON_BACKGROUND: String = "on_background" +let COLOR_SURFACE: String = "surface" +let COLOR_ON_SURFACE: String = "on_surface" +let COLOR_SURFACE_VARIANT: String = "surface_variant" +let COLOR_ERROR: String = "error" +let COLOR_ON_ERROR: String = "on_error" +let COLOR_OUTLINE: String = "outline" +let COLOR_OUTLINE_VARIANT: String = "outline_variant" + +// A resolved RGBA quadruple, encoded as JSON for transport across the FFI. +type Rgba { + r: Int // 0-255 + g: Int // 0-255 + b: Int // 0-255 + a: Int // 0-255 (we store alpha *256 to keep everything integer in El today) +} + +fn rgba(r: Int, g: Int, b: Int, a: Int) -> Rgba { + { "r": r, "g": g, "b": b, "a": a } +} + +fn rgba_to_css(c: Rgba) -> String { + "rgba(" + int_to_str(c.r) + "," + int_to_str(c.g) + "," + int_to_str(c.b) + + "," + int_to_str(c.a) + ")" +} + +fn parse_hex(hex: String) -> Rgba { + let s: String = hex + if str_starts_with(s, "#") { let s = str_slice(s, 1, str_len(s)) } + let r: Int = hex_to_int(str_slice(s, 0, 2)) + let g: Int = hex_to_int(str_slice(s, 2, 4)) + let b: Int = hex_to_int(str_slice(s, 4, 6)) + let a: Int = 255 + if str_len(s) > 6 { let a = hex_to_int(str_slice(s, 6, 8)) } + { "r": r, "g": g, "b": b, "a": a } +} + +// ── ColorScheme: semantic token -> Rgba ────────────────────────────────────── + +type ColorScheme { + primary: String // hex + on_primary: String + primary_container: String + secondary: String + on_secondary: String + background: String + on_background: String + surface: String + on_surface: String + surface_variant: String + error: String + on_error: String + outline: String + outline_variant: String +} + +fn color_scheme_light() -> ColorScheme { + { "primary": "#3b82f6", "on_primary": "#ffffff", "primary_container": "#dbeafe", + "secondary": "#a855f7", "on_secondary": "#ffffff", + "background": "#ffffff", "on_background": "#0f172a", + "surface": "#f8fafc", "on_surface": "#0f172a", "surface_variant": "#e2e8f0", + "error": "#dc2626", "on_error": "#ffffff", + "outline": "#cbd5e1", "outline_variant": "#e2e8f0" } +} + +fn color_scheme_dark() -> ColorScheme { + { "primary": "#60a5fa", "on_primary": "#0f172a", "primary_container": "#1e3a8a", + "secondary": "#c084fc", "on_secondary": "#0f172a", + "background": "#0f172a", "on_background": "#f8fafc", + "surface": "#1e293b", "on_surface": "#f8fafc", "surface_variant": "#334155", + "error": "#f87171", "on_error": "#0f172a", + "outline": "#475569", "outline_variant": "#334155" } +} + +// Resolve a semantic color token to its hex value in the active scheme. +fn resolve_color(scheme: ColorScheme, token: String) -> String { + if str_eq(token, "primary") { return scheme.primary } + if str_eq(token, "on_primary") { return scheme.on_primary } + if str_eq(token, "primary_container") { return scheme.primary_container } + if str_eq(token, "secondary") { return scheme.secondary } + if str_eq(token, "on_secondary") { return scheme.on_secondary } + if str_eq(token, "background") { return scheme.background } + if str_eq(token, "on_background") { return scheme.on_background } + if str_eq(token, "surface") { return scheme.surface } + if str_eq(token, "on_surface") { return scheme.on_surface } + if str_eq(token, "surface_variant") { return scheme.surface_variant } + if str_eq(token, "error") { return scheme.error } + if str_eq(token, "on_error") { return scheme.on_error } + if str_eq(token, "outline") { return scheme.outline } + if str_eq(token, "outline_variant") { return scheme.outline_variant } + "#000000" +} + +// ── Spacing scale ──────────────────────────────────────────────────────────── + +type SpacingScale { + xs: Int + sm: Int + md: Int + lg: Int + xl: Int + xxl: Int +} + +fn spacing_default() -> SpacingScale { + { "xs": 4, "sm": 8, "md": 16, "lg": 24, "xl": 32, "xxl": 48 } +} + +fn resolve_spacing(s: SpacingScale, token: String) -> Int { + if str_eq(token, "xs") { return s.xs } + if str_eq(token, "sm") { return s.sm } + if str_eq(token, "md") { return s.md } + if str_eq(token, "lg") { return s.lg } + if str_eq(token, "xl") { return s.xl } + if str_eq(token, "xxl") { return s.xxl } + s.md +} + +// ── Radius / shadow ────────────────────────────────────────────────────────── + +type RadiusScale { + none: Int + sm: Int + md: Int + lg: Int + full: Int +} + +fn radius_default() -> RadiusScale { + { "none": 0, "sm": 4, "md": 8, "lg": 16, "full": 9999 } +} + +type ShadowScale { + sm: String // CSS box-shadow string + md: String + lg: String +} + +fn shadow_light() -> ShadowScale { + { "sm": "0 1px 2px rgba(0,0,0,0.05)", + "md": "0 4px 6px rgba(0,0,0,0.1)", + "lg": "0 10px 15px rgba(0,0,0,0.1)" } +} + +fn shadow_dark() -> ShadowScale { + { "sm": "0 1px 2px rgba(0,0,0,0.4)", + "md": "0 4px 6px rgba(0,0,0,0.5)", + "lg": "0 10px 15px rgba(0,0,0,0.6)" } +} + +// ── Typography ─────────────────────────────────────────────────────────────── + +type TextStyle { + font_family: String + font_size: Int + font_weight: Int // 100..900 + line_height: Int // px + letter_spacing: Int // hundredths of em +} + +fn text_style_default() -> TextStyle { + { "font_family": "system-ui, -apple-system, sans-serif", + "font_size": 16, "font_weight": 400, "line_height": 24, "letter_spacing": 0 } +} + +// ── Theme ──────────────────────────────────────────────────────────────────── + +let THEME_MODE_LIGHT: String = "light" +let THEME_MODE_DARK: String = "dark" +let THEME_MODE_SYSTEM: String = "system" + +type Theme { + mode: String // light | dark | system + colors: ColorScheme + spacing: SpacingScale + radius: RadiusScale + shadows: ShadowScale + typography: TextStyle +} + +fn theme_default_light() -> Theme { + { "mode": "light", "colors": color_scheme_light(), "spacing": spacing_default(), + "radius": radius_default(), "shadows": shadow_light(), "typography": text_style_default() } +} + +fn theme_default_dark() -> Theme { + { "mode": "dark", "colors": color_scheme_dark(), "spacing": spacing_default(), + "radius": radius_default(), "shadows": shadow_dark(), "typography": text_style_default() } +} + +// ── StyleSheet — named rule sets ───────────────────────────────────────────── +// +// A StyleSheet is a JSON object mapping selector -> declarations. +// Components apply by name: element.apply("card") + +fn stylesheet_new() -> String { + "{}" +} + +fn stylesheet_define(sheet: String, name: String, css_decls: String) -> String { + json_set(sheet, name, css_decls) +} + +fn stylesheet_resolve(sheet: String, name: String) -> String { + json_get(sheet, name) +} + +// ── CSS emit ───────────────────────────────────────────────────────────────── +// +// Server-side render: theme → CSS custom properties block. +// Emit as a " +} + +fn text_style_to_css(ts: TextStyle) -> String { + "font-family:" + ts.font_family + + ";font-size:" + int_to_str(ts.font_size) + "px" + + ";font-weight:" + int_to_str(ts.font_weight) + + ";line-height:" + int_to_str(ts.line_height) + "px" +} + +// ── Entry — smoke test ─────────────────────────────────────────────────────── + +let theme: Theme = theme_default_light() +println("[el-style] light primary = " + theme.colors.primary) +println("[el-style] css vars = " + theme_to_css_vars(theme)) diff --git a/ui/crates/el-style/src/modifier.rs b/ui/vessels/el-style/src/modifier.rs similarity index 100% rename from ui/crates/el-style/src/modifier.rs rename to ui/vessels/el-style/src/modifier.rs diff --git a/ui/crates/el-style/src/radius.rs b/ui/vessels/el-style/src/radius.rs similarity index 100% rename from ui/crates/el-style/src/radius.rs rename to ui/vessels/el-style/src/radius.rs diff --git a/ui/crates/el-style/src/shadow.rs b/ui/vessels/el-style/src/shadow.rs similarity index 100% rename from ui/crates/el-style/src/shadow.rs rename to ui/vessels/el-style/src/shadow.rs diff --git a/ui/crates/el-style/src/spacing.rs b/ui/vessels/el-style/src/spacing.rs similarity index 100% rename from ui/crates/el-style/src/spacing.rs rename to ui/vessels/el-style/src/spacing.rs diff --git a/ui/crates/el-style/src/stylesheet.rs b/ui/vessels/el-style/src/stylesheet.rs similarity index 100% rename from ui/crates/el-style/src/stylesheet.rs rename to ui/vessels/el-style/src/stylesheet.rs diff --git a/ui/crates/el-style/src/theme.rs b/ui/vessels/el-style/src/theme.rs similarity index 100% rename from ui/crates/el-style/src/theme.rs rename to ui/vessels/el-style/src/theme.rs diff --git a/ui/crates/el-style/src/typography.rs b/ui/vessels/el-style/src/typography.rs similarity index 100% rename from ui/crates/el-style/src/typography.rs rename to ui/vessels/el-style/src/typography.rs diff --git a/ui/crates/el-ui-compiler/Cargo.toml b/ui/vessels/el-ui-compiler/Cargo.toml similarity index 100% rename from ui/crates/el-ui-compiler/Cargo.toml rename to ui/vessels/el-ui-compiler/Cargo.toml diff --git a/ui/vessels/el-ui-compiler/manifest.el b/ui/vessels/el-ui-compiler/manifest.el new file mode 100644 index 0000000..a9f7b2c --- /dev/null +++ b/ui/vessels/el-ui-compiler/manifest.el @@ -0,0 +1,28 @@ +// el-ui-compiler — El→browser-target component compiler. +// +// RUNTIME GAP: This vessel is a STUB. The bootstrap El compiler (`elc`) +// emits C only — there is no JavaScript or WebAssembly backend yet. The +// El surface here defines the public contract (`Compiler`, +// `compile_component`, `compile_app`) but the codegen step returns a +// marker module that throws at load time. Once elc gains a JS/Wasm +// backend, swap the stage bodies for real implementations and ship. +// +// The Rust crate (vessels/el-ui-compiler) has the real lexer / parser / +// semantic / codegen pipeline and is the source of truth until the El +// version becomes self-hosting. + +vessel "el-ui-compiler" { + version "0.1.0" + description "El→JS component compiler (STUB; awaiting JS backend in elc)" + authors ["Will Anderson "] + edition "2026" +} + +dependencies { + el-platform "1.0" +} + +build { + entry "src/main.el" + output "dist/" +} diff --git a/ui/crates/el-ui-compiler/src/ast.rs b/ui/vessels/el-ui-compiler/src/ast.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/ast.rs rename to ui/vessels/el-ui-compiler/src/ast.rs diff --git a/ui/crates/el-ui-compiler/src/codegen.rs b/ui/vessels/el-ui-compiler/src/codegen.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/codegen.rs rename to ui/vessels/el-ui-compiler/src/codegen.rs diff --git a/ui/crates/el-ui-compiler/src/error.rs b/ui/vessels/el-ui-compiler/src/error.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/error.rs rename to ui/vessels/el-ui-compiler/src/error.rs diff --git a/ui/crates/el-ui-compiler/src/lexer.rs b/ui/vessels/el-ui-compiler/src/lexer.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/lexer.rs rename to ui/vessels/el-ui-compiler/src/lexer.rs diff --git a/ui/crates/el-ui-compiler/src/lib.rs b/ui/vessels/el-ui-compiler/src/lib.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/lib.rs rename to ui/vessels/el-ui-compiler/src/lib.rs diff --git a/ui/vessels/el-ui-compiler/src/main.el b/ui/vessels/el-ui-compiler/src/main.el new file mode 100644 index 0000000..f4d4fef --- /dev/null +++ b/ui/vessels/el-ui-compiler/src/main.el @@ -0,0 +1,142 @@ +// el-ui-compiler — El→browser-target component compiler (STUB). +// +// The Rust crate transforms `.el` component files into JavaScript using a +// real lexer/parser/codegen pipeline backed by the el-ui runtime +// (`el-ui.js`). This El surface is a placeholder. +// +// RUNTIME GAP — load-bearing: +// `elc` (the bootstrap El compiler) currently emits C only. There is no +// JavaScript or WebAssembly backend yet, so the El surface cannot +// actually compile a component to JS today. +// +// What this vessel provides: +// - The shape of the public API (Compiler with runtime_path, +// compile_component, compile_app). +// - A `CompileResult` record consumers can branch on. +// - Stub bodies that return a deterministic "not yet implemented" +// module string so downstream tooling can integration-test against +// a stable contract. +// +// When elc gains a JS/Wasm backend, swap the bodies of `lex`, `parse`, +// `semantic_check`, and `codegen` for real implementations — the public +// surface should not need to change. + +// ── Errors ────────────────────────────────────────────────────────────────── + +fn err_lex() -> String { "elc.lex" } +fn err_parse() -> String { "elc.parse" } +fn err_semantic() -> String { "elc.semantic" } +fn err_codegen() -> String { "elc.codegen" } +fn err_backend_missing() -> String { "elc.backend_missing" } + +// ── Compile result ────────────────────────────────────────────────────────── +// +// Modeled as JSON since the runtime's product types and field accessors +// don't yet support the Bool/String mix we want here cleanly. + +fn result_ok(code: String) -> String { + "{\"ok\":true,\"code\":" + json_quote(code) + ",\"error\":\"\"}" +} + +fn result_err(error: String) -> String { + "{\"ok\":false,\"code\":\"\",\"error\":\"" + error + "\"}" +} + +// Minimal JSON string quoter (escape backslash + double-quote only). +// elc still doesn't expose a json_string_quote builtin. +fn json_quote(s: String) -> String { + let escaped: String = str_replace(s, "\\", "\\\\") + let escaped = str_replace(escaped, "\"", "\\\"") + "\"" + escaped + "\"" +} + +fn result_ok_p(result_json: String) -> Bool { + json_get_bool(result_json, "ok") +} + +// ── Compiler config ───────────────────────────────────────────────────────── + +fn compiler_default_runtime_path() -> String { "./el-ui.js" } +fn compiler_default_target() -> String { "js" } + +fn compiler_new() -> String { + "{\"runtime_path\":\"./el-ui.js\",\"target\":\"js\"}" +} + +fn compiler_with_runtime(c_json: String, path: String) -> String { + json_set(c_json, "runtime_path", json_quote(path)) +} + +fn compiler_with_target(c_json: String, target: String) -> String { + json_set(c_json, "target", json_quote(target)) +} + +fn compiler_runtime_path(c_json: String) -> String { + json_get_string(c_json, "runtime_path") +} + +fn compiler_target(c_json: String) -> String { + json_get_string(c_json, "target") +} + +// ── Pipeline stages (stubs) ───────────────────────────────────────────────── +// +// Real implementations live in the Rust crate (lexer.rs, parser.rs, +// semantic.rs, codegen.rs). These El stubs only validate inputs and +// produce a marker payload so callers can wire the contract. + +fn lex(source: String) -> String { + if str_eq(source, "") { return "" } + "[]" // would be tokens JSON +} + +fn parse(tokens_json: String) -> String { + if str_eq(tokens_json, "") { return "" } + "[]" // would be Component AST JSON +} + +fn semantic_check(ast_json: String) -> String { + ast_json // pass-through stub +} + +fn codegen(c_json: String, ast_json: String) -> String { + let runtime: String = compiler_runtime_path(c_json) + let preamble: String = "// AUTOGENERATED by el-ui-compiler (STUB)\n" + let import_line: String = "import { register, h } from \"" + runtime + "\";\n" + let body: String = "throw new Error(\"" + err_backend_missing() + + ": el-ui-compiler has no JS backend yet; emit C with elc instead\");\n" + preamble + import_line + body +} + +// ── Public entry points ───────────────────────────────────────────────────── + +fn compile_component(c_json: String, source: String) -> String { + let tokens: String = lex(source) + if str_eq(tokens, "") { return result_err(err_lex()) } + let ast: String = parse(tokens) + if str_eq(ast, "") { return result_err(err_parse()) } + let checked: String = semantic_check(ast) + if str_eq(checked, "") { return result_err(err_semantic()) } + let js: String = codegen(c_json, checked) + result_ok(js) +} + +// `entry_source` is the app entry; `components_json` is a JSON object map of +// name -> source text. We compile each component first, then the entry. +fn compile_app(c_json: String, entry_source: String, components_json: String) -> String { + // RUNTIME GAP: no json_object_keys() yet, so we cannot iterate the map + // generically here. Real implementation will iterate and short-circuit + // on first compile error. For the stub we just compile the entry. + compile_component(c_json, entry_source) +} + +// ── Entry — smoke test ────────────────────────────────────────────────────── + +let c: String = compiler_new() +let c = compiler_with_runtime(c, "./el-ui.js") +let r: String = compile_component(c, "component Hello { template {

    hi

    } }") +if result_ok_p(r) { + println("[el-ui-compiler] STUB compiled (target=" + compiler_target(c) + ")") +} else { + println("[el-ui-compiler] error: " + json_get_string(r, "error")) +} diff --git a/ui/crates/el-ui-compiler/src/main.rs b/ui/vessels/el-ui-compiler/src/main.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/main.rs rename to ui/vessels/el-ui-compiler/src/main.rs diff --git a/ui/crates/el-ui-compiler/src/parser.rs b/ui/vessels/el-ui-compiler/src/parser.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/parser.rs rename to ui/vessels/el-ui-compiler/src/parser.rs diff --git a/ui/crates/el-ui-compiler/src/tests.rs b/ui/vessels/el-ui-compiler/src/tests.rs similarity index 100% rename from ui/crates/el-ui-compiler/src/tests.rs rename to ui/vessels/el-ui-compiler/src/tests.rs From 7c00922cbd8e1ed28bfc0096860c980fbeb62808 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Tue, 5 May 2026 04:20:38 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(el-html):=20tighten=20manifest=20?= =?UTF-8?q?=E2=80=94=20correct=20description,=20remove=20unused=20dependen?= =?UTF-8?q?cies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit el-html is a standalone atomic emit layer; it has no runtime dependency on el-style or el-layout (those vessels depend on el-html for SSR, not the other way around). --- ui/vessels/el-html/manifest.el | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/vessels/el-html/manifest.el b/ui/vessels/el-html/manifest.el index 5156e77..49ad261 100644 --- a/ui/vessels/el-html/manifest.el +++ b/ui/vessels/el-html/manifest.el @@ -1,14 +1,12 @@ vessel "el-html" { version "0.1.0" - description "HTML generation and templating vessel for el-ui" + description "Atomic HTML emit primitives — server-side rendering building blocks" authors ["Will Anderson "] edition "2026" } dependencies { el-platform "1.0" - el-style "1.0" - el-layout "1.0" } build {