diff --git a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex index 1b7f60bc..64b56d80 100644 --- a/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex +++ b/packages/desktop_ui/lib/desktop_ui/renderer/mapper.ex @@ -7,6 +7,7 @@ defmodule DesktopUi.Renderer.Mapper do alias DesktopUi.Widget alias UnifiedIUR.Element alias UnifiedIUR.Element.Child + alias UnifiedIUR.Style, as: CanonicalStyle @component_kinds UnifiedIUR.Widgets.Components.kinds() @component_kind_values @component_kinds ++ Enum.map(@component_kinds, &Atom.to_string/1) @@ -1274,13 +1275,107 @@ defmodule DesktopUi.Renderer.Mapper do defp base_opts(element) do [ - styles: normalize_styles(attr(element, :styles)), + styles: canonical_styles(element), metadata: metadata_opts(element), disabled: first_present([attr(element, :disabled), metadata_attr(element, :disabled)], false) ] end + defp canonical_styles(%Element{} = element) do + element + |> attr(:styles) + |> normalize_styles() + |> Map.new() + |> Map.merge(translate_style_attachment(attr(element, :style))) + |> DesktopUi.Style.normalize() + end + + defp translate_style_attachment(nil), do: %{} + + defp translate_style_attachment(style) do + style = CanonicalStyle.new(style) + text = normalize_nested_map(style.text) + border = normalize_nested_map(style.border) + + %{} + |> maybe_put(:fg, portable_color(style.foreground)) + |> maybe_put(:bg, portable_color(style.background)) + |> maybe_put(:attrs, text_attrs(text)) + |> maybe_put(:border, border_style(border, style.border_color)) + |> maybe_put(:padding, padding_style(style.spacing)) + |> maybe_put(:tone, map_get(style.emphasis, :tone)) + |> maybe_put(:semantic_role, map_get(style.emphasis, :role)) + |> maybe_put(:state_variants, translate_state_variants(style.state_variants)) + end + + defp translate_state_variants(variants) when variants == %{}, do: nil + + defp translate_state_variants(variants) do + variants + |> Map.new(fn {state, style} -> + {normalize_key(state), translate_style_attachment(style)} + end) + |> Enum.reject(fn {_state, styles} -> styles == %{} end) + |> Map.new() + |> case do + state_styles when state_styles == %{} -> nil + state_styles -> state_styles + end + end + + defp border_style(border, color) do + border + |> maybe_put(:color, portable_color(color)) + |> case do + values when values == %{} -> nil + values -> values + end + end + + defp padding_style(spacing) do + spacing = normalize_nested_map(spacing) + + spacing + |> Map.take([:padding, :padding_top, :padding_right, :padding_bottom, :padding_left]) + |> case do + values when values == %{} -> nil + values -> values + end + end + + defp text_attrs(text) do + text + |> Enum.reduce([], fn + {:bold?, true}, attrs -> [:bold | attrs] + {:italic?, true}, attrs -> [:italic | attrs] + {:underline?, true}, attrs -> [:underline | attrs] + {:strikethrough?, true}, attrs -> [:strikethrough | attrs] + _other, attrs -> attrs + end) + |> Enum.reverse() + |> case do + [] -> nil + attrs -> attrs + end + end + + defp portable_color(nil), do: nil + defp portable_color(%{mode: :named, name: name}), do: name + defp portable_color(%{"mode" => :named, "name" => name}), do: name + defp portable_color(%{mode: :indexed, index: index}), do: {:indexed, index} + defp portable_color(%{"mode" => :indexed, "index" => index}), do: {:indexed, index} + + defp portable_color(%{mode: :rgb, red: red, green: green, blue: blue}) do + "#" <> Base.encode16(<>, case: :lower) + end + + defp portable_color(%{"mode" => :rgb, "red" => red, "green" => green, "blue" => blue}) do + "#" <> Base.encode16(<>, case: :lower) + end + + defp portable_color(value), do: value + defp metadata_opts(element) do [ label: label_text(element), @@ -1449,6 +1544,9 @@ defmodule DesktopUi.Renderer.Mapper do defp normalize_styles(styles) when is_map(styles), do: Map.to_list(styles) defp normalize_styles(styles) when is_list(styles), do: styles + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + defp placeholder_child(id, role) do Widget.new(:spacer, id: "#{id}-#{role}-placeholder") end diff --git a/packages/desktop_ui/test/desktop_ui/renderer_test.exs b/packages/desktop_ui/test/desktop_ui/renderer_test.exs index 197d75ed..a1f02c55 100644 --- a/packages/desktop_ui/test/desktop_ui/renderer_test.exs +++ b/packages/desktop_ui/test/desktop_ui/renderer_test.exs @@ -3,6 +3,7 @@ defmodule DesktopUi.RendererTest do alias UnifiedIUR.Element alias UnifiedIUR.Element.Child + alias UnifiedIUR.Style test "renderer maps foundational canonical widgets and layouts into native desktop widgets" do element = @@ -48,6 +49,42 @@ defmodule DesktopUi.RendererTest do assert DesktopUi.Renderer.validation_state() == :advanced_mapper_ready end + test "renderer realizes CSS-derived canonical style without parsing authored CSS" do + element = + Element.new(:widget, :text, + id: "css-copy", + attributes: %{ + content: "CSS-derived", + style: + Style.new(%{ + foreground: "#2563eb", + background: "#eff6ff", + border_color: "#1d4ed8", + text: %{underline?: true}, + spacing: %{padding_top: "4px"}, + state_variants: %{focused: %{foreground: "#ffffff"}}, + extra: %{ + css: %{ + properties: ["color"], + declarations: [%{selector: "#css-copy", property: "color"}] + } + } + }) + } + ) + + assert {:ok, widget} = DesktopUi.Renderer.render(element) + + assert widget.styles.fg == "#2563eb" + assert widget.styles.bg == "#eff6ff" + assert widget.styles.border == %{color: "#1d4ed8"} + assert widget.styles.attrs == [:underline] + assert widget.styles.padding == %{padding_top: "4px"} + assert widget.styles.state_variants.focused.fg == "#ffffff" + refute Map.has_key?(widget.styles, :css) + refute inspect(widget.styles) =~ "declarations" + end + test "renderer maps advanced canonical widgets, display systems, and layers into native desktop widgets" do element = Element.new(:layer, :multi_window, diff --git a/packages/elm_ui/lib/elm_ui/frontend_runtime/style_realization.ex b/packages/elm_ui/lib/elm_ui/frontend_runtime/style_realization.ex index 618a0ddb..5aeca632 100644 --- a/packages/elm_ui/lib/elm_ui/frontend_runtime/style_realization.ex +++ b/packages/elm_ui/lib/elm_ui/frontend_runtime/style_realization.ex @@ -28,8 +28,10 @@ defmodule ElmUi.FrontendRuntime.StyleRealization do css_vars: %{} |> maybe_put_var("--ui-tone", Map.get(resolved, :tone)) + |> maybe_put_var("--ui-foreground", Map.get(resolved, :foreground)) |> maybe_put_var("--ui-surface", Map.get(resolved, :surface)) |> maybe_put_var("--ui-background", Map.get(resolved, :background)) + |> maybe_put_var("--ui-border-color", Map.get(resolved, :border_color)) |> maybe_put_var("--ui-border", Map.get(resolved, :border)) |> maybe_put_var("--ui-emphasis", Map.get(resolved, :emphasis)) |> maybe_put_var("--ui-typography", Map.get(resolved, :typography)), diff --git a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex index b3a9b1d0..002effbd 100644 --- a/packages/elm_ui/lib/elm_ui/renderer/canonical.ex +++ b/packages/elm_ui/lib/elm_ui/renderer/canonical.ex @@ -1576,14 +1576,23 @@ defmodule ElmUi.Renderer.Canonical do defp translate_style_attachment(style) when style == %{}, do: %{} defp translate_style_attachment(style) do - style = normalize_map(style) + style = UnifiedIUR.Style.new(style) text = normalize_map(map_get(style, :text, %{})) visibility = normalize_map(map_get(style, :visibility, %{})) emphasis = normalize_map(map_get(style, :emphasis, %{})) + border = normalize_map(map_get(style, :border, %{})) state_variants = normalize_map(map_get(style, :state_variants, %{})) translated = %{} + |> maybe_put(:foreground, portable_color(map_get(style, :foreground))) + |> maybe_put(:background, portable_color(map_get(style, :background))) + |> maybe_put(:border_color, portable_color(map_get(style, :border_color))) + |> maybe_put(:spacing, compact_map(normalize_map(map_get(style, :spacing, %{})))) + |> maybe_put(:sizing, compact_map(normalize_map(map_get(style, :sizing, %{})))) + |> maybe_put(:alignment, compact_map(normalize_map(map_get(style, :alignment, %{})))) + |> maybe_put(:border, if(border == %{}, do: nil, else: border)) + |> maybe_put(:text, text_attrs(text)) |> maybe_put(:tone, map_get(emphasis, :tone)) |> maybe_put(:visibility, if(map_get(visibility, :hidden?) == true, do: :hidden, else: nil)) |> maybe_put(:emphasis, text_emphasis(text)) @@ -1638,6 +1647,35 @@ defmodule ElmUi.Renderer.Canonical do end end + defp text_attrs(text) do + text + |> Enum.filter(fn + {_key, true} -> true + _other -> false + end) + |> Map.new() + |> case do + attrs when attrs == %{} -> nil + attrs -> attrs + end + end + + defp portable_color(nil), do: nil + defp portable_color(%{mode: :named, name: name}), do: name + defp portable_color(%{"mode" => :named, "name" => name}), do: name + defp portable_color(%{mode: :indexed, index: index}), do: {:indexed, index} + defp portable_color(%{"mode" => :indexed, "index" => index}), do: {:indexed, index} + + defp portable_color(%{mode: :rgb, red: red, green: green, blue: blue}) do + "#" <> Base.encode16(<>, case: :lower) + end + + defp portable_color(%{"mode" => :rgb, "red" => red, "green" => green, "blue" => blue}) do + "#" <> Base.encode16(<>, case: :lower) + end + + defp portable_color(value), do: value + defp content_text(%Element{} = element, default \\ nil) do first_present([group_attr(element, :content, :text), attr(element, :content)], default) end diff --git a/packages/elm_ui/lib/elm_ui/style.ex b/packages/elm_ui/lib/elm_ui/style.ex index b0b7c30c..95f7a857 100644 --- a/packages/elm_ui/lib/elm_ui/style.ex +++ b/packages/elm_ui/lib/elm_ui/style.ex @@ -18,12 +18,17 @@ defmodule ElmUi.Style do :typography, :tone, :color_role, + :foreground, :size, :spacing, + :sizing, :align, + :alignment, :surface, :background, + :border_color, :border, + :text, :visibility, :emphasis ] diff --git a/packages/elm_ui/test/elm_ui/renderer_test.exs b/packages/elm_ui/test/elm_ui/renderer_test.exs index 0e17faed..bf0bfd78 100644 --- a/packages/elm_ui/test/elm_ui/renderer_test.exs +++ b/packages/elm_ui/test/elm_ui/renderer_test.exs @@ -2,6 +2,7 @@ defmodule ElmUi.RendererTest do use ExUnit.Case, async: true alias UnifiedIUR.Element + alias UnifiedIUR.Style alias ElmUi.Renderer.Error alias ElmUi.Widget @@ -18,6 +19,42 @@ defmodule ElmUi.RendererTest do assert ElmUi.Renderer.render(element) == ElmUi.Renderer.render(element) end + test "renderer realizes CSS-derived canonical style without parsing authored CSS" do + element = + Element.new(:widget, :text, + id: :css_copy, + attributes: %{ + content: "CSS-derived", + style: + Style.new(%{ + foreground: "#2563eb", + background: "#eff6ff", + border_color: "#1d4ed8", + text: %{underline?: true}, + spacing: %{padding_top: "4px"}, + state_variants: %{focused: %{foreground: "#ffffff"}}, + extra: %{ + css: %{ + properties: ["color"], + declarations: [%{selector: "#css-copy", property: "color"}] + } + } + }) + } + ) + + assert {:ok, %Widget{} = widget} = ElmUi.Renderer.render(element) + + assert widget.styles.foreground == "#2563eb" + assert widget.styles.background == "#eff6ff" + assert widget.styles.border_color == "#1d4ed8" + assert widget.styles.text == %{underline?: true} + assert widget.styles.spacing == %{padding_top: "4px"} + assert widget.styles.state_variants.focused.foreground == "#ffffff" + refute Map.has_key?(widget.styles, :css) + refute inspect(widget.styles) =~ "declarations" + end + test "renderer rejects unsupported canonical kinds with structured diagnostics" do element = Element.new(:widget, :timeline, id: :timeline_root, attributes: %{title: "Unsupported"}) diff --git a/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex b/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex index 07055128..bd8252e5 100644 --- a/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex +++ b/packages/terminal_ui/lib/terminal_ui/renderer/mapper.ex @@ -6,6 +6,7 @@ defmodule TerminalUi.Renderer.Mapper do alias TerminalUi.Renderer.Error alias UnifiedIUR.{Binding, Element, Interaction} alias UnifiedIUR.Element.Child + alias UnifiedIUR.Style, as: CanonicalStyle @component_kinds UnifiedIUR.Widgets.Components.kinds() @component_kind_values @component_kinds ++ Enum.map(@component_kinds, &Atom.to_string/1) @@ -1232,6 +1233,8 @@ defmodule TerminalUi.Renderer.Mapper do end defp base_opts(element) do + canonical_styles = canonical_styles(element) + [] |> maybe_put(:label, label_text(element, nil)) |> maybe_put(:description, description(element)) @@ -1248,19 +1251,45 @@ defmodule TerminalUi.Renderer.Mapper do :semantic_role, first_present([group_attr(element, :style, :semantic_role), attr(element, :semantic_role)]) ) - |> maybe_put(:fg, first_present([group_attr(element, :style, :fg), attr(element, :fg)])) - |> maybe_put(:bg, first_present([group_attr(element, :style, :bg), attr(element, :bg)])) + |> maybe_put( + :fg, + first_present([ + group_attr(element, :style, :fg), + attr(element, :fg), + Map.get(canonical_styles, :fg) + ]) + ) + |> maybe_put( + :bg, + first_present([ + group_attr(element, :style, :bg), + attr(element, :bg), + Map.get(canonical_styles, :bg) + ]) + ) |> maybe_put( :attrs, - first_present([group_attr(element, :style, :attrs), attr(element, :attrs)]) + first_present([ + group_attr(element, :style, :attrs), + attr(element, :attrs), + Map.get(canonical_styles, :attrs) + ]) ) |> maybe_put( :border, - first_present([group_attr(element, :style, :border), attr(element, :border)]) + first_present([ + Map.get(canonical_styles, :border), + group_attr(element, :style, :border), + attr(element, :border) + ]) ) |> maybe_put( :padding, - first_present([group_attr(element, :style, :padding), attr(element, :padding)]) + first_present([ + Map.get(canonical_styles, :padding), + group_attr(element, :style, :padding), + attr(element, :padding) + ]) ) |> maybe_put( :theme_tokens, @@ -1270,12 +1299,113 @@ defmodule TerminalUi.Renderer.Mapper do :style_refs, first_present([group_attr(element, :style, :style_refs), attr(element, :style_refs)]) ) + |> maybe_put( + :state_variants, + first_present([ + Map.get(canonical_styles, :state_variants), + group_attr(element, :style, :state_variants), + attr(element, :state_variants) + ]) + ) |> maybe_put( :degradation, first_present([group_attr(element, :style, :degradation), attr(element, :degradation)]) ) end + defp canonical_styles(%Element{} = element) do + element + |> attr(:styles) + |> normalize_style_map() + |> Map.merge(translate_style_attachment(attr(element, :style))) + |> TerminalUi.Style.normalize() + end + + defp translate_style_attachment(nil), do: %{} + + defp translate_style_attachment(style) do + style = CanonicalStyle.new(style) + text = normalize_nested_map(style.text) + border = normalize_nested_map(style.border) + + %{} + |> maybe_put(:fg, portable_color(style.foreground)) + |> maybe_put(:bg, portable_color(style.background)) + |> maybe_put(:attrs, text_attrs(text)) + |> maybe_put(:border, border_style(border, style.border_color)) + |> maybe_put(:padding, padding_style(style.spacing)) + |> maybe_put(:semantic_role, map_get(style.emphasis, :role)) + |> maybe_put(:intensity, map_get(style.emphasis, :intensity)) + |> maybe_put(:state_variants, translate_state_variants(style.state_variants)) + end + + defp translate_state_variants(variants) when variants == %{}, do: nil + + defp translate_state_variants(variants) do + variants + |> Map.new(fn {state, style} -> {normalize_key(state), translate_style_attachment(style)} end) + |> Enum.reject(fn {_state, styles} -> styles == %{} end) + |> Map.new() + |> case do + state_styles when state_styles == %{} -> nil + state_styles -> state_styles + end + end + + defp border_style(border, color) do + border + |> maybe_put(:color, portable_color(color)) + |> case do + values when values == %{} -> nil + values -> values + end + end + + defp padding_style(spacing) do + spacing = normalize_nested_map(spacing) + + spacing + |> Map.take([:padding, :padding_top, :padding_right, :padding_bottom, :padding_left]) + |> case do + values when values == %{} -> nil + values -> values + end + end + + defp text_attrs(text) do + text + |> Enum.reduce([], fn + {:bold?, true}, attrs -> [:bold | attrs] + {:dim?, true}, attrs -> [:dim | attrs] + {:italic?, true}, attrs -> [:italic | attrs] + {:underline?, true}, attrs -> [:underline | attrs] + {:blink?, true}, attrs -> [:blink | attrs] + {:reverse?, true}, attrs -> [:reverse | attrs] + _other, attrs -> attrs + end) + |> Enum.reverse() + |> case do + [] -> nil + attrs -> attrs + end + end + + defp portable_color(nil), do: nil + defp portable_color(%{mode: :named, name: name}), do: name + defp portable_color(%{"mode" => :named, "name" => name}), do: name + defp portable_color(%{mode: :indexed, index: index}), do: {:indexed, index} + defp portable_color(%{"mode" => :indexed, "index" => index}), do: {:indexed, index} + + defp portable_color(%{mode: :rgb, red: red, green: green, blue: blue}) do + "#" <> Base.encode16(<>, case: :lower) + end + + defp portable_color(%{"mode" => :rgb, "red" => red, "green" => green, "blue" => blue}) do + "#" <> Base.encode16(<>, case: :lower) + end + + defp portable_color(value), do: value + defp binding_name(element) do case first_binding(element) do %Binding{name: name} when not is_nil(name) -> name @@ -1502,6 +1632,12 @@ defmodule TerminalUi.Renderer.Mapper do Enum.find(values, default, &(not is_nil(&1))) end + defp normalize_style_map(nil), do: %{} + defp normalize_style_map(styles) when is_map(styles), do: normalize_nested_map(styles) + + defp normalize_style_map(styles) when is_list(styles), + do: styles |> Map.new() |> normalize_nested_map() + defp maybe_put(opts, _key, nil), do: opts defp maybe_put(opts, key, value) when is_list(opts), do: Keyword.put(opts, key, value) defp maybe_put(opts, key, value) when is_map(opts), do: Map.put(opts, key, value) diff --git a/packages/terminal_ui/test/terminal_ui/renderer_mapper_test.exs b/packages/terminal_ui/test/terminal_ui/renderer_mapper_test.exs index deed04c9..bd757783 100644 --- a/packages/terminal_ui/test/terminal_ui/renderer_mapper_test.exs +++ b/packages/terminal_ui/test/terminal_ui/renderer_mapper_test.exs @@ -3,6 +3,7 @@ defmodule TerminalUi.RendererMapperTest do alias TerminalUi.Renderer alias UnifiedIUR.{Binding, Canvas, Element, Forms, Interaction, Layer, Layout} + alias UnifiedIUR.Style alias UnifiedIUR.Widgets.{Advanced, Data, Feedback, Foundational, Input, Navigation} test "canonical foundational screens map into the native widget surface" do @@ -41,6 +42,42 @@ defmodule TerminalUi.RendererMapperTest do assert Enum.at(widget.children, 3).bindings == %{current: :active_section} end + test "canonical renderer realizes CSS-derived style without parsing authored CSS" do + element = + Element.new(:widget, :text, + id: "css-copy", + attributes: %{ + content: "CSS-derived", + style: + Style.new(%{ + foreground: "#2563eb", + background: "#eff6ff", + border_color: "#1d4ed8", + text: %{underline?: true}, + spacing: %{padding_top: "4px"}, + state_variants: %{focused: %{foreground: "#ffffff"}}, + extra: %{ + css: %{ + properties: ["color"], + declarations: [%{selector: "#css-copy", property: "color"}] + } + } + }) + } + ) + + assert {:ok, widget} = Renderer.render(element) + + assert widget.styles.fg == "#2563eb" + assert widget.styles.bg == "#eff6ff" + assert widget.styles.border == %{color: "#1d4ed8"} + assert widget.styles.attrs == [:underline] + assert widget.styles.padding == %{padding_top: "4px"} + assert widget.styles.state_variants.focused.fg == "#ffffff" + refute Map.has_key?(widget.styles, :css) + refute inspect(widget.styles) =~ "declarations" + end + test "canonical screens mount through the same runtime realization model as native screens" do element = Layout.column(