trmnl_preview 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +180 -5
  4. data/bin/rake +6 -6
  5. data/bin/trmnlp +1 -0
  6. data/db/data/form_fields.yml +24 -0
  7. data/db/data/framework_versions.yml +72 -0
  8. data/lib/trmnlp/api_client.rb +41 -28
  9. data/lib/trmnlp/app.rb +73 -44
  10. data/lib/trmnlp/browser_pool.rb +82 -0
  11. data/lib/trmnlp/cli.rb +24 -11
  12. data/lib/trmnlp/commands/base.rb +33 -10
  13. data/lib/trmnlp/commands/build.rb +13 -8
  14. data/lib/trmnlp/commands/clone.rb +12 -7
  15. data/lib/trmnlp/commands/init.rb +17 -13
  16. data/lib/trmnlp/commands/lint.rb +42 -0
  17. data/lib/trmnlp/commands/list.rb +40 -0
  18. data/lib/trmnlp/commands/login.rb +28 -13
  19. data/lib/trmnlp/commands/pull.rb +14 -6
  20. data/lib/trmnlp/commands/push.rb +29 -19
  21. data/lib/trmnlp/commands/serve.rb +32 -3
  22. data/lib/trmnlp/commands.rb +3 -1
  23. data/lib/trmnlp/config/app.rb +6 -3
  24. data/lib/trmnlp/config/plugin.rb +56 -14
  25. data/lib/trmnlp/config/project.rb +59 -7
  26. data/lib/trmnlp/config.rb +3 -1
  27. data/lib/trmnlp/context.rb +21 -224
  28. data/lib/trmnlp/errors.rb +15 -0
  29. data/lib/trmnlp/form_field.rb +42 -0
  30. data/lib/trmnlp/framework_version.rb +69 -0
  31. data/lib/trmnlp/image_quantizer.rb +58 -0
  32. data/lib/trmnlp/lint/check.rb +31 -0
  33. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  34. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  35. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  36. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  37. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  38. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  39. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  40. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  41. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  42. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  43. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  44. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  45. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  46. data/lib/trmnlp/lint/source.rb +42 -0
  47. data/lib/trmnlp/lint.rb +39 -0
  48. data/lib/trmnlp/paths.rb +28 -8
  49. data/lib/trmnlp/poller.rb +105 -0
  50. data/lib/trmnlp/renderer.rb +87 -0
  51. data/lib/trmnlp/reporter.rb +28 -0
  52. data/lib/trmnlp/screen.rb +16 -0
  53. data/lib/trmnlp/screen_generator.rb +11 -217
  54. data/lib/trmnlp/screenshot.rb +96 -0
  55. data/lib/trmnlp/transform_backend/http.rb +107 -0
  56. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  57. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  58. data/lib/trmnlp/transform_client.rb +47 -0
  59. data/lib/trmnlp/transform_pipeline.rb +65 -0
  60. data/lib/trmnlp/user_data_assembler.rb +96 -0
  61. data/lib/trmnlp/version.rb +1 -1
  62. data/lib/trmnlp/watcher.rb +60 -0
  63. data/lib/trmnlp.rb +6 -10
  64. data/templates/init/bin/trmnlp +1 -1
  65. data/templates/init/src/settings.yml +1 -0
  66. data/templates/init/src/transform.py.example +14 -0
  67. data/trmnl_preview.gemspec +34 -34
  68. data/web/public/index.css +6 -0
  69. data/web/public/index.js +31 -18
  70. data/web/views/index.erb +6 -1
  71. data/web/views/render_html.erb +4 -2
  72. metadata +81 -56
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ # Strategy host for serverless transform execution. Local plugin
5
+ # development hits the Subprocess backend by default — transforms
6
+ # run in trmnlp's own image alongside Ruby, no daemon required.
7
+ # Setting `serverless_daemon_url` in .trmnlp.yml swaps in the Http
8
+ # backend so plugin authors can target a real remote transform
9
+ # daemon (production parity testing, shared team daemons, etc.).
10
+ class TransformClient
11
+ # Mirrors the remote daemon's ExecResponse. `output` is the canonical
12
+ # JSON result; `stdout` carries user prints.
13
+ Result = Data.define(:stdout, :stderr, :output, :exit_code, :duration_ms, :error) do
14
+ def success? = error.nil? && exit_code.zero?
15
+ end
16
+
17
+ attr_reader :backend
18
+
19
+ # Returns nil when serverless is disabled in .trmnlp.yml so the
20
+ # pipeline can short-circuit without a per-request check.
21
+ def self.from_config(project_config)
22
+ runtime = project_config.transform_runtime
23
+ return nil if runtime.nil? || runtime.to_s == 'disabled'
24
+
25
+ new(backend: backend_for(project_config))
26
+ end
27
+
28
+ def self.backend_for(project_config)
29
+ if (url = project_config.serverless_daemon_url)
30
+ TransformBackend::Http.new(url: url, api_key: project_config.serverless_daemon_api_key)
31
+ else
32
+ TransformBackend::Subprocess.new
33
+ end
34
+ end
35
+
36
+ def initialize(backend:)
37
+ @backend = backend
38
+ end
39
+
40
+ def execute(code:, language:, stdin: '', timeout_seconds: 30)
41
+ backend.execute(code:, language:, stdin:, timeout_seconds:)
42
+ end
43
+ end
44
+ end
45
+
46
+ require_relative 'transform_backend/subprocess'
47
+ require_relative 'transform_backend/http'
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'reporter'
6
+ require_relative 'transform_client'
7
+
8
+ module TRMNLP
9
+ # Pipes assembled merge_variables through src/transform.{py,rb,php,js}
10
+ # when a serverless runtime is configured. Mirrors the hosted
11
+ # transform behavior: the transform receives the data (including the
12
+ # trmnl namespace) on stdin and its stdout JSON replaces the data.
13
+ # Failure modes surface via #error (rendered in the preview UI), not
14
+ # raised.
15
+ class TransformPipeline
16
+ attr_reader :error
17
+
18
+ def initialize(config:, paths:, reporter: Reporter.new)
19
+ @config = config
20
+ @paths = paths
21
+ @reporter = reporter
22
+ end
23
+
24
+ def call(data)
25
+ @error = nil
26
+ transform_path, inferred_language = paths.transform_file
27
+ return data unless transform_path && client
28
+
29
+ run(transform_path, inferred_language, data)
30
+ end
31
+
32
+ def reset! = @client = nil
33
+
34
+ private
35
+
36
+ attr_reader :config, :paths, :reporter
37
+
38
+ def client = @client ||= TransformClient.from_config(config.project)
39
+
40
+ def run(path, inferred_language, data)
41
+ language = config.plugin.serverless_language || inferred_language
42
+ result = client.execute(code: path.read, stdin: JSON.generate(data), language:)
43
+ return record_failure(result, data) unless result.success?
44
+
45
+ parse_output(result.output, data)
46
+ end
47
+
48
+ def record_failure(result, fallback)
49
+ @error = result.error || "transform exited #{result.exit_code}: #{result.stderr.strip}"
50
+ reporter.info("transform failed: #{@error}")
51
+ fallback
52
+ end
53
+
54
+ def parse_output(output, fallback)
55
+ transformed = JSON.parse(output)
56
+ transformed.is_a?(Hash) ? transformed : wrap_array(transformed)
57
+ rescue JSON::ParserError => e
58
+ @error = "transform produced non-JSON output: #{e.message}"
59
+ reporter.info(@error)
60
+ fallback
61
+ end
62
+
63
+ def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
64
+ end
65
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/time'
5
+ require 'json'
6
+
7
+ module TRMNLP
8
+ class UserDataAssembler
9
+ DEFAULT_DEVICE_WIDTH = 800
10
+ DEFAULT_DEVICE_HEIGHT = 480
11
+
12
+ def initialize(config:, paths:, transform_pipeline:)
13
+ @config = config
14
+ @paths = paths
15
+ @transform_pipeline = transform_pipeline
16
+ end
17
+
18
+ # Assembles the merged data hash. The trmnl namespace is built first,
19
+ # layered with static_data / cached polled data / user_data_overrides,
20
+ # then piped through the transform. The trmnl namespace is re-applied
21
+ # after the transform so device, user, and plugin_settings survive
22
+ # even when the transform doesn't pass them through.
23
+ def call(device: {})
24
+ namespace = base_trmnl_data(device:)
25
+ merged = assemble(namespace)
26
+ result = transform_pipeline.call(merged)
27
+ result['trmnl'] = namespace['trmnl']
28
+ result
29
+ end
30
+
31
+ def device_from_params(params)
32
+ { 'width' => params[:width]&.to_i, 'height' => params[:height]&.to_i }.compact
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :config, :paths, :transform_pipeline
38
+
39
+ def assemble(namespace)
40
+ data = namespace.dup
41
+ merge_source_data!(data)
42
+ data.deep_merge!(config.project.user_data_overrides)
43
+ data
44
+ end
45
+
46
+ def merge_source_data!(data)
47
+ if config.plugin.static?
48
+ data.merge!(config.plugin.static_data)
49
+ elsif paths.user_data.exist?
50
+ data.merge!(JSON.parse(paths.user_data.read))
51
+ end
52
+ end
53
+
54
+ def base_trmnl_data(device:)
55
+ { 'trmnl' => trmnl_namespace(device:) }
56
+ end
57
+
58
+ def trmnl_namespace(device:)
59
+ {
60
+ 'user' => user_namespace,
61
+ 'device' => device_namespace(device),
62
+ 'system' => { 'timestamp_utc' => Time.now.utc.to_i },
63
+ 'plugin_settings' => plugin_settings_namespace
64
+ }
65
+ end
66
+
67
+ def user_namespace
68
+ tz = ActiveSupport::TimeZone.find_tzinfo(config.project.time_zone)
69
+ iana = tz.name
70
+ {
71
+ 'name' => 'name', 'first_name' => 'first_name', 'last_name' => 'last_name',
72
+ 'locale' => 'en', 'time_zone' => ActiveSupport::TimeZone::MAPPING.invert[iana] || iana,
73
+ 'time_zone_iana' => iana, 'utc_offset' => tz.utc_offset
74
+ }
75
+ end
76
+
77
+ def device_namespace(device)
78
+ {
79
+ 'friendly_id' => 'ABC123', 'percent_charged' => 85.0, 'wifi_strength' => 90,
80
+ 'height' => device['height'] || DEFAULT_DEVICE_HEIGHT,
81
+ 'width' => device['width'] || DEFAULT_DEVICE_WIDTH
82
+ }
83
+ end
84
+
85
+ def plugin_settings_namespace
86
+ {
87
+ 'instance_name' => 'instance_name',
88
+ 'strategy' => config.plugin.strategy,
89
+ 'dark_mode' => config.plugin.dark_mode,
90
+ 'polling_headers' => config.plugin.polling_headers_encoded,
91
+ 'polling_url' => config.plugin.polling_url_text,
92
+ 'custom_fields_values' => config.project.custom_fields
93
+ }
94
+ end
95
+ end
96
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = "0.7.1".freeze
4
+ VERSION = '0.8.0'
5
5
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'filewatcher'
4
+
5
+ require_relative 'reporter'
6
+
7
+ module TRMNLP
8
+ class Watcher
9
+ def initialize(config:, user_data_assembler:, transform_pipeline:, reporter: Reporter.new)
10
+ @config = config
11
+ @user_data_assembler = user_data_assembler
12
+ @transform_pipeline = transform_pipeline
13
+ @reporter = reporter
14
+ end
15
+
16
+ def start
17
+ @start ||= Thread.new { run }
18
+ end
19
+
20
+ def on_change(&block)
21
+ @view_change_callback = block
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :config, :user_data_assembler, :transform_pipeline, :reporter
27
+
28
+ def run
29
+ loop do
30
+ watch_cycle
31
+ rescue StandardError => e
32
+ reporter.info("error during live render: #{e}")
33
+ end
34
+ end
35
+
36
+ def watch_cycle
37
+ Filewatcher.new(config.project.watch_paths).watch do |changes|
38
+ reload_config!
39
+ notify(changes)
40
+ end
41
+ end
42
+
43
+ def reload_config!
44
+ config.project.reload!
45
+ config.plugin.reload!
46
+ transform_pipeline.reset! # config may have changed runtime config
47
+ end
48
+
49
+ # NOTE: transform.* changes don't trigger a re-poll — the transform
50
+ # runs inside user_data on every render against the cached polled
51
+ # response (or static_data), so editing the transform updates the
52
+ # preview without re-fetching the API.
53
+ def notify(changes)
54
+ data = user_data_assembler.call
55
+ return unless @view_change_callback
56
+
57
+ changes.each_key { |path| @view_change_callback.call(File.basename(path, '.liquid'), data) }
58
+ end
59
+ end
60
+ end
data/lib/trmnlp.rb CHANGED
@@ -2,13 +2,9 @@
2
2
 
3
3
  module TRMNLP; end
4
4
  require 'oj'
5
- Oj.mimic_JSON()
6
- require_relative "trmnlp/config"
7
- require_relative "trmnlp/context"
8
- require_relative "trmnlp/version"
9
-
10
- module TRMNLP
11
- VIEWS = %w{full half_horizontal half_vertical quadrant}
12
-
13
- class Error < StandardError; end
14
- end
5
+ Oj.mimic_JSON
6
+ require_relative 'trmnlp/errors'
7
+ require_relative 'trmnlp/config'
8
+ require_relative 'trmnlp/context'
9
+ require_relative 'trmnlp/screen'
10
+ require_relative 'trmnlp/version'
@@ -1,4 +1,4 @@
1
- #! /bin/bash
1
+ #!/usr/bin/env bash
2
2
  #
3
3
  # This script was automatically generated by `trmnlp init` but it's yours to modify!
4
4
  # Use this opportunity to set up environment variables, install dependencies, etc.
@@ -9,6 +9,7 @@ no_screen_padding: 'no'
9
9
  dark_mode: 'no'
10
10
  static_data: ''
11
11
  polling_verb: get
12
+ framework_version: latest
12
13
  polling_url: ''
13
14
  polling_headers: ''
14
15
  name: My Plugin
@@ -0,0 +1,14 @@
1
+ # Optional serverless transform.
2
+ # Rename this file to `transform.py` (or transform.rb / transform.php / transform.js)
3
+ # and it runs automatically — serverless transforms are enabled by default.
4
+ # (Set `transform_runtime: disabled` in .trmnlp.yml to turn them off.)
5
+ #
6
+ # Define a `run(input)` function that takes the polled response (parsed
7
+ # JSON, including the trmnl namespace) and returns any JSON-serialisable
8
+ # value. The bundled transform-runtime wraps this and handles stdin/stdout.
9
+ # This matches the hosted serverless contract — the same code runs in production.
10
+
11
+
12
+ def run(input):
13
+ # Reshape `input` however you like, then return.
14
+ return input
@@ -1,27 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "lib/trmnlp/version"
3
+ require_relative 'lib/trmnlp/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "trmnl_preview"
6
+ spec.name = 'trmnl_preview'
7
7
  spec.version = TRMNLP::VERSION
8
- spec.authors = ["Rockwell Schrock"]
9
- spec.email = ["rockwell@schrock.me"]
8
+ spec.authors = ['Rockwell Schrock']
9
+ spec.email = ['rockwell@schrock.me']
10
10
 
11
- spec.summary = "Local web server to preview TRMNL plugins"
12
- spec.description = "Automatically rebuild and preview TRNML plugins in multiple views"
13
- spec.homepage = "https://github.com/usetrmnl/trmnlp"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.0.0"
11
+ spec.summary = 'Local web server to preview TRMNL plugins'
12
+ spec.description = 'Automatically rebuild and preview TRNML plugins in multiple views'
13
+ spec.homepage = 'https://github.com/usetrmnl/trmnlp'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
16
 
17
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
17
+ spec.metadata['allowed_push_host'] = 'https://rubygems.org'
18
18
 
19
- spec.metadata["homepage_uri"] = spec.homepage
20
- spec.metadata["source_code_uri"] = "https://github.com/usetrmnl/trmnlp"
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/usetrmnl/trmnlp'
21
+ spec.metadata['rubygems_mfa_required'] = 'true'
21
22
 
22
23
  spec.files = Dir.chdir(__dir__) do
23
24
  [
24
25
  'bin/**/*',
26
+ 'db/**/*',
25
27
  'lib/**/*',
26
28
  'templates/**/{*,.*}',
27
29
  'web/**/*',
@@ -31,37 +33,35 @@ Gem::Specification.new do |spec|
31
33
  'trmnl_preview.gemspec'
32
34
  ].flat_map { |glob| Dir[glob] }
33
35
  end
34
- spec.bindir = "bin"
35
- spec.executables = ["trmnlp"]
36
- spec.require_paths = ["lib"]
37
-
36
+ spec.bindir = 'bin'
37
+ spec.executables = ['trmnlp']
38
+ spec.require_paths = ['lib']
38
39
 
39
40
  # Web server
40
- spec.add_dependency "sinatra", "~> 4.1"
41
- spec.add_dependency "rackup", "~> 2.2"
42
- spec.add_dependency "puma", "~> 6.5"
43
- spec.add_dependency "faye-websocket", "~> 0.11.3"
41
+ spec.add_dependency 'puma', '~> 8.0'
42
+ spec.add_dependency 'rackup', '~> 2.2'
43
+ spec.add_dependency 'sinatra', '~> 4.1'
44
44
 
45
45
  # HTML rendering
46
- spec.add_dependency "activesupport", "~> 8.0"
47
- spec.add_dependency "trmnl-liquid", "~> 0.4.0"
46
+ spec.add_dependency 'activesupport', '~> 8.0'
47
+ spec.add_dependency 'trmnl-liquid', '~> 0.7.0'
48
48
 
49
49
  # PNG rendering
50
50
  # spec.add_dependency 'puppeteer-ruby', '~> 0.45.6'
51
- spec.add_dependency 'selenium-webdriver', '~> 4.39'
52
- spec.add_dependency 'mini_magick', '~> 4.12.0'
51
+ spec.add_dependency 'mini_magick', '~> 5.3'
52
+ spec.add_dependency 'selenium-webdriver', '~> 4.44'
53
53
 
54
54
  # Utilities
55
- spec.add_dependency "filewatcher", "~> 2.1"
56
- spec.add_dependency "faraday", "~> 2.1"
57
- spec.add_dependency "faraday-multipart", "~> 1.1"
58
- spec.add_dependency "xdg", "~> 9.1"
59
- spec.add_dependency "rubyzip", "~> 2.3.0"
60
- spec.add_dependency "thor", "~> 1.3"
61
- spec.add_dependency "oj", "~> 3.16.9"
62
- spec.add_dependency "tzinfo-data", "~> 1.2025"
63
- spec.add_dependency "pathname", "~> 0.4"
64
- spec.add_dependency "rexml", "~> 3.4"
55
+ spec.add_dependency 'cgi', '~> 0.5'
56
+ spec.add_dependency 'faraday', '~> 2.1'
57
+ spec.add_dependency 'faraday-multipart', '~> 1.1'
58
+ spec.add_dependency 'filewatcher', '~> 3.0'
59
+ spec.add_dependency 'oj', '~> 3.17'
60
+ spec.add_dependency 'rexml', '~> 3.4'
61
+ spec.add_dependency 'rubyzip', '~> 3.3'
62
+ spec.add_dependency 'thor', '~> 1.3'
63
+ spec.add_dependency 'tzinfo-data', '~> 1.2025'
64
+ spec.add_dependency 'xdg', '~> 10.2'
65
65
 
66
66
  # For more information and examples about making a new gem, check out our
67
67
  # guide at: https://bundler.io/guides/creating_gem.html
data/web/public/index.css CHANGED
@@ -104,6 +104,12 @@ pre > code {
104
104
  }
105
105
  }
106
106
 
107
+ .payload-size {
108
+ font-size: 0.85em;
109
+ opacity: 0.6;
110
+ margin: 0.5em 0 0.2em;
111
+ }
112
+
107
113
  @media (prefers-color-scheme: dark) {
108
114
  :root {
109
115
  --bg-color: #191b21;
data/web/public/index.js CHANGED
@@ -1,14 +1,15 @@
1
1
  const trmnlp = {};
2
2
 
3
3
  trmnlp.connectLiveRender = function () {
4
- const ws = new WebSocket("/live_reload");
4
+ // EventSource reconnects automatically, so no manual retry loop is needed.
5
+ const source = new EventSource("/live_reload");
5
6
 
6
- ws.onopen = function () {
7
- console.log("Connected to live reload socket");
7
+ source.onopen = function () {
8
+ console.log("Connected to live reload stream");
8
9
  };
9
10
 
10
- ws.onmessage = function (msg) {
11
- const payload = JSON.parse(msg.data);
11
+ source.onmessage = function (event) {
12
+ const payload = JSON.parse(event.data);
12
13
 
13
14
  if (payload.type === "reload") {
14
15
  trmnlp.fetchPreview();
@@ -16,37 +17,48 @@ trmnlp.connectLiveRender = function () {
16
17
  hljs.highlightAll();
17
18
  }
18
19
  };
19
-
20
- ws.onclose = function () {
21
- console.log("Reconnecting to live reload socket...");
22
- setTimeout(trmnlp.connectLiveRender, 1000);
23
- };
24
20
  };
25
21
 
26
22
 
27
23
  trmnlp.fetchPreview = function (pickerState) {
28
- const screenClasses = (pickerState?.screenClasses || trmnlp.picker.state.screenClasses).join(" ");
24
+ const state = pickerState || trmnlp.picker?.state;
25
+ const screenClasses = (state?.screenClasses || []).join(" ");
29
26
  const encodedScreenClasses = encodeURIComponent(screenClasses);
30
27
  let src = `/render/${trmnlp.view}.${trmnlp.formatSelect.value}?screen_classes=${encodedScreenClasses}`;
31
28
 
32
- // If requesting a PNG, also include dimensions, dark mode, and color depth
33
- if (trmnlp.formatSelect.value === 'png') {
34
- const state = pickerState || trmnlp.picker.state;
29
+ // Pass dimensions for both HTML and PNG renders so trmnl.device.{width,height}
30
+ // in the Liquid context tracks the picker model selection.
31
+ if (state) {
35
32
  const width = encodeURIComponent(state.width);
36
33
  const height = encodeURIComponent(state.height);
37
- const isDarkMode = state.isDarkMode ? 1 : 0;
34
+ src += `&width=${width}&height=${height}`;
35
+ }
38
36
 
39
- // derive numeric color depth from classes like 'screen--1bit'
37
+ // PNG-only: dark mode + color depth from palette
38
+ if (trmnlp.formatSelect.value === 'png' && state) {
39
+ const isDarkMode = state.isDarkMode ? 1 : 0;
40
40
  const grays = state.palette.grays || 2;
41
41
  const colorDepth = Math.ceil(Math.log2(grays));
42
-
43
- src += `&width=${width}&height=${height}&color_depth=${colorDepth}`;
42
+ src += `&color_depth=${colorDepth}`;
44
43
  }
45
44
 
46
45
  trmnlp.spinner.style.display = "inline-block";
47
46
  trmnlp.iframe.src = src;
48
47
  };
49
48
 
49
+ trmnlp.refreshUserData = async function (state) {
50
+ if (!state) return;
51
+ const params = new URLSearchParams({ width: state.width, height: state.height });
52
+ try {
53
+ const response = await fetch(`/data?${params}`);
54
+ if (!response.ok) return;
55
+ trmnlp.userData.textContent = await response.text();
56
+ hljs.highlightAll();
57
+ } catch (e) {
58
+ console.warn("Failed to refresh user-data:", e);
59
+ }
60
+ };
61
+
50
62
  document.addEventListener("DOMContentLoaded", async function () {
51
63
  trmnlp.view = document.querySelector("meta[name='trmnl-view']").content;
52
64
  trmnlp.iframe = document.querySelector("iframe");
@@ -77,6 +89,7 @@ document.addEventListener("DOMContentLoaded", async function () {
77
89
  trmnlp.iframe.style.height = `${event.detail.height}px`;
78
90
 
79
91
  trmnlp.fetchPreview(event.detail);
92
+ trmnlp.refreshUserData(event.detail);
80
93
  });
81
94
 
82
95
  trmnlp.picker = await TRMNLPicker.create('picker-form', { localStorageKey: 'trmnlp-picker' });
data/web/views/index.erb CHANGED
@@ -60,9 +60,14 @@
60
60
  </div>
61
61
  </menu>
62
62
 
63
+ <% if @transform_error %>
64
+ <div class="transform-error" style="padding: 0.5em 0.75em; margin: 0.5em 0; background: #fee; border: 1px solid #c33; border-radius: 4px; color: #900; font-family: monospace; white-space: pre-wrap;">Transform error: <%= h @transform_error %></div>
65
+ <% end %>
66
+
63
67
  <iframe style="width: 800px; height: 480px; border: 1px solid black; background: white;">Rendering…</iframe>
64
68
 
65
- <pre id="user-data"><code><%= @user_data %></code></pre>
69
+ <div class="payload-size">Payload: <%= format_bytes(@payload_size) %></div>
70
+ <pre id="user-data"><code><%= h @user_data %></code></pre>
66
71
  </main>
67
72
  </body>
68
73
  </html>
@@ -1,8 +1,10 @@
1
1
  <!DOCTYPE html>
2
2
  <html>
3
3
  <head>
4
- <link rel="stylesheet" href="https://trmnl.com/css/latest/plugins.css" />
5
- <script src="https://trmnl.com/js/latest/plugins.js"></script>
4
+ <link rel="stylesheet" href="<%= @framework.css_url %>" />
5
+ <script src="<%= @framework.js_url %>"></script>
6
+ <meta name="trmnl-framework-version" content="<%= @framework.number %>" />
7
+ <meta name="trmnl-framework-pinned" content="<%= @framework.pinned? %>" />
6
8
 
7
9
  <!-- Begin Inter font -->
8
10
  <link rel="preconnect" href="https://fonts.googleapis.com">