tina4ruby 3.13.51 → 3.13.52

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acbf145f4e91880ca9b0763788134071b914c5923f3cdc84fc459a333097bb11
4
- data.tar.gz: 04e0793ef84e6d044e578d6183526c05c45edd3f68dd04f2c1801458296d130c
3
+ metadata.gz: b6a859f05cd2ac97b5e38ab9ee678cd8419f91be1aca0dedaed1e1d63b268968
4
+ data.tar.gz: 4454362a269ef77b5992a0ec4d2e30e6dd64134ed64265d1e8fd6f9432fcbc0f
5
5
  SHA512:
6
- metadata.gz: 6cd653a5682bf5f1257ae0290a36d5b6784afb16c67e5433b56480c25d750003fa3f9d1560aa1246c2ec71c7720ce0073cf9e1f10703b7e031effe3a5d36e29c
7
- data.tar.gz: 17e2ccf01f62cd8b5ce17d7606c50f8e2882e7e6c1ad32fd0021ff980b7850fccda44a818c2370ae832387fae745e22fefbb510f49a0a7c4f187f31d4cc38356
6
+ metadata.gz: 595d84b72e3b75a263f54ff93859a9f92f52fc76434b7633d149d11a2fa8a172a72478a13b9aa649ea0189a1f975d0a98daf7afa7cbceeb5bec9636433c95dad
7
+ data.tar.gz: 66634157b45125bb796277f54a9b93d12ab721c237b0dec39450d78d69ba4f3162972a178ef742607d45e4cc33d572c3460d7d336e0133e7c29ea902e21b68ae
data/lib/tina4/cli.rb CHANGED
@@ -200,6 +200,10 @@ module Tina4
200
200
  # Register health check endpoint
201
201
  Tina4::Health.register!
202
202
 
203
+ # Register the always-on Frond {% live %} refresh endpoint
204
+ # (GET /__frond/live/{name}) so server-rendered live blocks can poll/SSE.
205
+ Tina4::Frond.register_live_endpoint!
206
+
203
207
  # Load route files
204
208
  load_routes(root_dir)
205
209
 
@@ -111,6 +111,7 @@ module Tina4
111
111
  "sqlite3" => "Tina4::Drivers::SqliteDriver",
112
112
  "postgres" => "Tina4::Drivers::PostgresDriver",
113
113
  "postgresql" => "Tina4::Drivers::PostgresDriver",
114
+ "pgsql" => "Tina4::Drivers::PostgresDriver",
114
115
  "mysql" => "Tina4::Drivers::MysqlDriver",
115
116
  "mssql" => "Tina4::Drivers::MssqlDriver",
116
117
  "sqlserver" => "Tina4::Drivers::MssqlDriver",
data/lib/tina4/frond.rb CHANGED
@@ -39,7 +39,23 @@ module Tina4
39
39
  @@class_globals = {}
40
40
  @@class_tests = {}
41
41
 
42
- # Clear the class-level globals/filters/tests registries.
42
+ # -- Live-block registries (server-rendered {% live %} regions) -----------
43
+ # A {% live %} block registers three things when its page first renders:
44
+ # * class_live_fragments[name] -> the raw body source, re-rendered on
45
+ # every refresh by the /__frond/live/<name>
46
+ # endpoint or push_live
47
+ # * class_live_sources[name] -> an optional data provider (live_source)
48
+ # that re-runs with the LIVE request each
49
+ # refresh, so auth re-applies (IDOR guard)
50
+ # * class_live_ws_paths[name] -> the ws path a `ws "path"` block declared,
51
+ # used as the push_live broadcast target
52
+ # These persist across requests in the long-lived server (parity with the
53
+ # Python master's class-level dicts and PHP's static registries).
54
+ @@class_live_fragments = {}
55
+ @@class_live_sources = {}
56
+ @@class_live_ws_paths = {}
57
+
58
+ # Clear the class-level globals/filters/tests/live registries.
43
59
  #
44
60
  # Useful in test fixtures to prevent leaking state between tests. Does
45
61
  # NOT affect built-in filters or globals — only user-registered ones.
@@ -47,6 +63,9 @@ module Tina4
47
63
  @@class_filters = {}
48
64
  @@class_globals = {}
49
65
  @@class_tests = {}
66
+ @@class_live_fragments = {}
67
+ @@class_live_sources = {}
68
+ @@class_live_ws_paths = {}
50
69
  end
51
70
 
52
71
  # -- Token types ----------------------------------------------------------
@@ -91,6 +110,18 @@ module Tina4
91
110
  SET_RE = /\Aset\s+(\w+)\s*=\s*(.+)\z/m
92
111
  INCLUDE_RE = /\Ainclude\s+["'](.+?)["'](?:\s+with\s+(.+))?\z/
93
112
  MACRO_RE = /\Amacro\s+(\w+)\s*\(([^)]*)\)/
113
+ # {% live "name" poll N | sse | ws "path" [src "url"] %}
114
+ LIVE_RE = /\Alive\s+["']([^"']+)["'](.*)\z/m
115
+ LIVE_WS_RE = /ws\s+["']([^"']+)["']/
116
+ LIVE_SRC_RE = /src\s+["']([^"']+)["']/
117
+
118
+ # Escape a value for use inside an HTML attribute on a live marker.
119
+ # Byte-identical order to the Python master / PHP liveAttr so the emitted
120
+ # marker element matches across all four frameworks.
121
+ def self.live_attr(value)
122
+ value.to_s.gsub("&", "&amp;").gsub('"', "&quot;")
123
+ .gsub("<", "&lt;").gsub(">", "&gt;")
124
+ end
94
125
  FROM_IMPORT_RE = /\Afrom\s+["'](.+?)["']\s+import\s+(.+)/
95
126
  CACHE_RE = /\Acache\s+["'](.+?)["']\s*(\d+)?/
96
127
  SPACELESS_RE = />\s+</
@@ -587,6 +618,9 @@ module Tina4
587
618
  when "cache"
588
619
  result, i = handle_cache(tokens, i, context)
589
620
  output << result
621
+ when "live"
622
+ result, i = handle_live(tokens, i, context)
623
+ output << result
590
624
  when "spaceless"
591
625
  result, i = handle_spaceless(tokens, i, context)
592
626
  output << result
@@ -1794,6 +1828,181 @@ module Tina4
1794
1828
  [rendered, i]
1795
1829
  end
1796
1830
 
1831
+ # Handle {% live "name" poll N | sse | ws "path" [src "url"] %}...{% endlive %}.
1832
+ #
1833
+ # Server-rendered live region. The body renders once for first paint, is
1834
+ # registered under <name> so the /__frond/live/<name> endpoint (or a
1835
+ # live_source provider) can re-render it, and is wrapped in a marker element
1836
+ # that frond.js wires to the chosen transport (poll / sse / ws). Mirrors the
1837
+ # Python master's _handle_live and PHP's handleLive.
1838
+ def handle_live(tokens, start, context)
1839
+ content, _, _ = strip_tag(tokens[start][1])
1840
+ m = LIVE_RE.match(content)
1841
+ raise 'live: expected {% live "name" poll N | sse | ws "path" %}' unless m
1842
+
1843
+ name = m[1]
1844
+ rest = (m[2] || "").strip
1845
+ parts = rest.split
1846
+ mode = parts[0] || ""
1847
+
1848
+ src = nil
1849
+ if (sm = LIVE_SRC_RE.match(rest))
1850
+ src = sm[1]
1851
+ end
1852
+ if src && (src.start_with?("http://") || src.start_with?("https://") || src.start_with?("//"))
1853
+ raise "live: src must be a same-origin path, not an absolute URL"
1854
+ end
1855
+
1856
+ interval = nil
1857
+ ws_path = nil
1858
+ case mode
1859
+ when "poll"
1860
+ raise 'live: poll requires seconds, e.g. {% live "x" poll 5 %}' unless parts[1]&.match?(/\A\d+\z/)
1861
+ interval = parts[1].to_i
1862
+ when "sse"
1863
+ # no extra config
1864
+ when "ws"
1865
+ wm = LIVE_WS_RE.match(rest)
1866
+ raise 'live: ws requires a path, e.g. {% live "x" ws "/ws/x" %}' unless wm
1867
+ ws_path = wm[1]
1868
+ else
1869
+ raise %(live: unknown transport "#{mode}" (use poll N, sse, or ws "path"))
1870
+ end
1871
+
1872
+ # Collect body tokens up to {% endlive %}. Nested live is unsupported.
1873
+ body_tokens = []
1874
+ i = start + 1
1875
+ while i < tokens.length
1876
+ if tokens[i][0] == BLOCK
1877
+ tc, _, _ = strip_tag(tokens[i][1])
1878
+ tag = tc.split[0] || ""
1879
+ raise "live: nested live blocks are not supported" if tag == "live"
1880
+ if tag == "endlive"
1881
+ i += 1
1882
+ break
1883
+ end
1884
+ body_tokens << tokens[i]
1885
+ else
1886
+ body_tokens << tokens[i]
1887
+ end
1888
+ i += 1
1889
+ end
1890
+
1891
+ # Register the raw body source so the auto endpoint can re-render it.
1892
+ @@class_live_fragments[name] = body_tokens.map { |t| t[1] }.join
1893
+
1894
+ endpoint = src || ("/__frond/live/" + name)
1895
+ attrs = [%(data-frond-live="#{Frond.live_attr(name)}"), %(id="live-#{Frond.live_attr(name)}")]
1896
+ case mode
1897
+ when "poll"
1898
+ attrs << 'data-mode="poll"' << %(data-interval="#{interval}") << %(data-src="#{Frond.live_attr(endpoint)}")
1899
+ when "sse"
1900
+ attrs << 'data-mode="sse"' << %(data-src="#{Frond.live_attr(endpoint)}")
1901
+ when "ws"
1902
+ @@class_live_ws_paths[name] = ws_path
1903
+ attrs << 'data-mode="ws"' << %(data-ws="#{Frond.live_attr(ws_path)}")
1904
+ end
1905
+
1906
+ first_paint = render_tokens(body_tokens.dup, context)
1907
+ [%(<div #{attrs.join(' ')}>#{first_paint}</div>), i]
1908
+ end
1909
+
1910
+ # -- Live-block class API (mirrors Python master + PHP static facade) -----
1911
+
1912
+ # Re-render a registered {% live %} fragment by name with fresh data.
1913
+ # Returns the rendered HTML, or nil if no fragment is registered under that
1914
+ # name yet (its page has not rendered). The /__frond/live/<name> endpoint
1915
+ # calls this after resolving the provider data.
1916
+ def self.render_live(name, data = {})
1917
+ source = @@class_live_fragments[name]
1918
+ return nil if source.nil?
1919
+
1920
+ new.render_string(source, data || {})
1921
+ end
1922
+
1923
+ # Register a data provider for a {% live %} block. Accepts a block OR a
1924
+ # callable (proc/lambda); it is invoked with the live request on every
1925
+ # refresh so auth re-applies (IDOR guard). Mirrors Python's @live_source.
1926
+ def self.live_source(name, callable = nil, &blk)
1927
+ @@class_live_sources[name] = callable || blk
1928
+ end
1929
+
1930
+ # The provider registered for a live block, or nil.
1931
+ def self.get_live_source(name)
1932
+ @@class_live_sources[name]
1933
+ end
1934
+
1935
+ # Whether a live fragment has been registered (its page rendered).
1936
+ def self.has_live_fragment?(name)
1937
+ @@class_live_fragments.key?(name)
1938
+ end
1939
+
1940
+ # The ws path a live block declared (data-ws), or nil.
1941
+ def self.get_live_ws_path(name)
1942
+ @@class_live_ws_paths[name]
1943
+ end
1944
+
1945
+ # Handle GET /__frond/live/{name}: resolve the provider, run it with the
1946
+ # live request (auth re-applies), re-render the fragment, return via the
1947
+ # response callable. 404 for unknown name / unrendered fragment. Mirrors
1948
+ # Python's live_endpoint and PHP's respondLive.
1949
+ def self.respond_live(request, response, name)
1950
+ provider = @@class_live_sources[name]
1951
+ if !@@class_live_fragments.key?(name) && provider.nil?
1952
+ return response.call("live block not found: #{name}", 404)
1953
+ end
1954
+
1955
+ context = {}
1956
+ unless provider.nil?
1957
+ result = provider.call(request)
1958
+ context = result.is_a?(Hash) ? result : {}
1959
+ end
1960
+
1961
+ html = render_live(name, context)
1962
+ return response.call("live fragment not registered yet: #{name}", 404) if html.nil?
1963
+
1964
+ response.call(html)
1965
+ end
1966
+
1967
+ # Re-render the '<name>' live fragment and push it to connected clients.
1968
+ # Broadcasts a {type,name,html} envelope over WebSocket to the block's
1969
+ # declared data-ws path (else a room named <name>). Returns the rendered
1970
+ # HTML, or nil if the fragment is not registered. Mirrors Python push_live
1971
+ # / PHP pushLive. The broadcast is best-effort — a missing/failed WS engine
1972
+ # never raises into the caller.
1973
+ def self.push_live(name, data = {})
1974
+ html = render_live(name, data)
1975
+ return nil if html.nil?
1976
+
1977
+ envelope = { "type" => "live", "name" => name, "html" => html }.to_json
1978
+ engine = (Tina4::WebSocket.current if defined?(Tina4::WebSocket))
1979
+ if engine
1980
+ begin
1981
+ ws_path = get_live_ws_path(name)
1982
+ if ws_path
1983
+ engine.broadcast(envelope, path: ws_path)
1984
+ else
1985
+ engine.broadcast_to_room(name, envelope)
1986
+ end
1987
+ rescue StandardError => e
1988
+ Tina4::Log.error("push_live(#{name}) broadcast failed: #{e.message}") if defined?(Tina4::Log)
1989
+ end
1990
+ end
1991
+ html
1992
+ end
1993
+
1994
+ # Register the always-on GET /__frond/live/{name} endpoint that re-renders
1995
+ # a live block on demand. Idempotent — guarded against a re-register after a
1996
+ # Router.clear! (specs / hot-reload rescans). Mirrors PHP App::registerLiveEndpoint.
1997
+ def self.register_live_endpoint!
1998
+ return if Tina4::Router.find_route("GET", "/__frond/live/live-probe")
1999
+
2000
+ Tina4::Router.add(
2001
+ "GET", "/__frond/live/{name}",
2002
+ lambda { |request, response, name| Tina4::Frond.respond_live(request, response, name) }
2003
+ )
2004
+ end
2005
+
1797
2006
  def handle_spaceless(tokens, start, context)
1798
2007
  body_tokens = []
1799
2008
  i = start + 1
@@ -558,6 +558,132 @@ var _frondModule = (() => {
558
558
  }
559
559
  });
560
560
  }
561
+ function _liveKey(el) {
562
+ const d = el.dataset;
563
+ return d && d.key ? d.key : null;
564
+ }
565
+ function _liveSyncAttrs(oldNode, newNode) {
566
+ const na = newNode.attributes;
567
+ for (let i = 0; i < na.length; i++) {
568
+ const a = na[i];
569
+ if (oldNode.getAttribute(a.name) !== a.value) oldNode.setAttribute(a.name, a.value);
570
+ }
571
+ const oa = Array.prototype.slice.call(oldNode.attributes);
572
+ oa.forEach(function(a) {
573
+ if (!newNode.hasAttribute(a.name)) oldNode.removeAttribute(a.name);
574
+ });
575
+ }
576
+ function _liveMorphNode(oldNode, newNode) {
577
+ const tag = newNode.tagName;
578
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
579
+ _liveSyncAttrs(oldNode, newNode);
580
+ if (oldNode.children.length || newNode.children.length) {
581
+ _liveReconcile(oldNode, newNode);
582
+ } else if (oldNode.innerHTML !== newNode.innerHTML) {
583
+ oldNode.innerHTML = newNode.innerHTML;
584
+ }
585
+ }
586
+ function _liveReconcile(parent, next) {
587
+ const oldKids = Array.prototype.slice.call(parent.children);
588
+ const newKids = Array.prototype.slice.call(next.children);
589
+ const oldByKey = {};
590
+ oldKids.forEach(function(c) {
591
+ const k = _liveKey(c);
592
+ if (k) oldByKey[k] = c;
593
+ });
594
+ const order = [];
595
+ for (let i = 0; i < newKids.length; i++) {
596
+ const nk = newKids[i];
597
+ const k = _liveKey(nk);
598
+ let match = null;
599
+ if (k && oldByKey[k]) {
600
+ match = oldByKey[k];
601
+ } else if (!k && oldKids[i] && !_liveKey(oldKids[i]) && oldKids[i].tagName === nk.tagName) {
602
+ match = oldKids[i];
603
+ }
604
+ if (match && match.tagName === nk.tagName) {
605
+ _liveMorphNode(match, nk);
606
+ reused.push(match);
607
+ order.push(match);
608
+ } else {
609
+ order.push(nk);
610
+ }
611
+ }
612
+ let cursor = parent.firstElementChild;
613
+ for (let i = 0; i < order.length; i++) {
614
+ const node = order[i];
615
+ if (node === cursor) {
616
+ cursor = cursor.nextElementSibling;
617
+ } else {
618
+ parent.insertBefore(node, cursor);
619
+ }
620
+ }
621
+ oldKids.forEach(function(c) {
622
+ if (order.indexOf(c) === -1 && c.parentNode === parent) parent.removeChild(c);
623
+ });
624
+ void reused;
625
+ }
626
+ function _liveSwap(container, html) {
627
+ const tmp = document.createElement("div");
628
+ tmp.innerHTML = html;
629
+ if (!tmp.children.length || !container.children.length) {
630
+ container.innerHTML = html;
631
+ return;
632
+ }
633
+ _liveReconcile(container, tmp);
634
+ }
635
+ function _liveWsUrl(path) {
636
+ if (/^wss?:\/\//.test(path)) return path;
637
+ const proto = typeof location !== "undefined" && location.protocol === "https:" ? "wss" : "ws";
638
+ return proto + "://" + location.host + path;
639
+ }
640
+ function _liveExtract(msg, name) {
641
+ if (msg && typeof msg === "object") {
642
+ if (msg.type === "live") {
643
+ if (name && msg.name && msg.name !== name) return null;
644
+ return msg.html != null ? String(msg.html) : null;
645
+ }
646
+ return null;
647
+ }
648
+ return typeof msg === "string" ? msg : null;
649
+ }
650
+ function liveInit(root) {
651
+ if (typeof document === "undefined") return;
652
+ const scope = root || document;
653
+ const blocks = scope.querySelectorAll("[data-frond-live]");
654
+ Array.prototype.slice.call(blocks).forEach(function(el) {
655
+ if (el.__frondLive) return;
656
+ el.__frondLive = true;
657
+ const mode = el.getAttribute("data-mode");
658
+ const name = el.getAttribute("data-frond-live");
659
+ if (mode === "poll") {
660
+ const src = el.getAttribute("data-src");
661
+ const interval = (parseInt(el.getAttribute("data-interval"), 10) || 5) * 1e3;
662
+ const timer = setInterval(function() {
663
+ if (typeof document !== "undefined" && document.hidden) return;
664
+ request(src, { method: "GET", onSuccess: function(data) {
665
+ _liveSwap(el, typeof data === "string" ? data : String(data));
666
+ } });
667
+ }, interval);
668
+ el.__frondLiveStop = function() {
669
+ clearInterval(timer);
670
+ };
671
+ } else if (mode === "ws") {
672
+ const sock = wsConnect(_liveWsUrl(el.getAttribute("data-ws")));
673
+ sock.on("message", function(msg) {
674
+ const h = _liveExtract(msg, name);
675
+ if (h !== null) _liveSwap(el, h);
676
+ });
677
+ el.__frondLiveStop = function() {
678
+ sock.close();
679
+ };
680
+ } else if (mode === "sse") {
681
+ if (typeof console !== "undefined" && console.warn) {
682
+ console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '" + name + "' shows first paint only. Use poll or ws.");
683
+ }
684
+ }
685
+ });
686
+ }
561
687
  var frond = {
562
688
  /** Core HTTP request. */
563
689
  request,
@@ -573,6 +699,8 @@ var _frondModule = (() => {
573
699
  ws: wsConnect,
574
700
  /** Server-Sent Events with auto-reconnect. */
575
701
  sse: sseConnect,
702
+ /** Wire {% live %} blocks (poll/ws) with keyed morph. Auto-runs on DOMContentLoaded. */
703
+ live: liveInit,
576
704
  /** Cookie helpers: get, set, remove. */
577
705
  cookie,
578
706
  /** Display alert message in #message element. */
@@ -593,8 +721,17 @@ var _frondModule = (() => {
593
721
  };
594
722
  if (typeof window !== "undefined") {
595
723
  window.frond = frond;
724
+ if (typeof document !== "undefined") {
725
+ if (document.readyState === "loading") {
726
+ document.addEventListener("DOMContentLoaded", function() {
727
+ liveInit();
728
+ });
729
+ } else {
730
+ liveInit();
731
+ }
732
+ }
596
733
  }
597
734
  return __toCommonJS(frond_exports);
598
735
  })();
599
- /* Frond v2.1.3 tina4.com */
736
+ /* Frond v2.2.0 - tina4.com */
600
737
  //# sourceMappingURL=frond.js.map
@@ -1,2 +1,2 @@
1
- var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
2
- /* Frond v2.1.3 tina4.com */
1
+ var _frondModule=(()=>{var E=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var H=Object.prototype.hasOwnProperty;var q=(t,s)=>{for(var e in s)E(t,e,{get:s[e],enumerable:!0})},D=(t,s,e,o)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of A(s))!H.call(t,n)&&n!==e&&E(t,n,{get:()=>s[n],enumerable:!(o=O(s,n))||o.enumerable});return t};var W=t=>D(E({},"__esModule",{value:!0}),t);var z={};q(z,{frond:()=>L});var y=null;function v(t,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let o=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(o,t,!0),y!==null&&n.setRequestHeader("Authorization","Bearer "+y),e.headers)for(let u in e.headers)Object.prototype.hasOwnProperty.call(e.headers,u)&&n.setRequestHeader(u,e.headers[u]);let c=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?c=e.body:typeof e.body=="object"?(c=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(c=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let u=n.getResponseHeader("FreshToken");u&&u!==""&&(y=u);let r=n.response;try{r=JSON.parse(r)}catch{}if(n.responseURL){let i=new URL(t,window.location.href).href;if(n.responseURL!==i){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(r,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(c)}function h(t,s){if(!t)return"";let e=new DOMParser,o=t.includes("<html>")?t:"<body>"+t+"</body></html>",c=e.parseFromString(o,"text/html").querySelector("body"),u=c.querySelectorAll("script");if(u.forEach(function(r){r.remove()}),s!==null){let r=document.getElementById(s);return r&&(c.children.length>0?r.replaceChildren.apply(r,Array.from(c.children)):r.innerHTML=c.innerHTML,u.forEach(function(i){let l=document.createElement("script");l.type="text/javascript",l.async=!0,i.src?l.src=i.src:l.textContent=i.textContent,r.appendChild(l)})),""}return u.forEach(function(r){let i=document.createElement("script");i.type="text/javascript",i.async=!0,i.textContent=r.textContent,document.body.appendChild(i)}),c.innerHTML}function _(t,s,e){let o=s||"content";v(t,{method:"GET",onSuccess:function(n,c){if(document.getElementById(o)){let u=h(n,o);e&&e(u,n)}else e&&e(n)}})}function R(t,s,e,o){let n=e||"content";v(t,{method:"POST",body:s,onSuccess:function(c){let u="";if(c&&c.message!==void 0)u=h(c.message,n);else if(document.getElementById(n))u=h(c,n);else{o&&o(c);return}o&&o(u,c)}})}var x={collect:function(t){let s=new FormData,e=document.querySelectorAll("#"+t+" select, #"+t+" input, #"+t+" textarea");for(let o=0;o<e.length;o++){let n=e[o];if(n.name==="formToken"&&y!==null&&(n.value=y),!!n.name)if(n.type==="file"){let c=n.files;if(c)for(let u=0;u<c.length;u++){let r=c[u];if(r!==void 0){let i=n.name;c.length>1&&!i.includes("[")&&(i=i+"[]"),s.append(i,r,r.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(t,s,e,o){let n=x.collect(t);R(s,n,e||"message",o)},show:function(t,s,e,o){let n=t.toUpperCase();(t==="create"||t==="edit")&&(n="GET"),t==="delete"&&(n="DELETE");let c=e||"form";v(s,{method:n,onSuccess:function(u){let r="";if(u&&u.message!==void 0)r=h(u.message,c);else if(document.getElementById(c))r=h(u,c);else{o&&o(u);return}o&&o(r)}})}};function M(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",send:function(f){if(!o||o.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");o.send(typeof f=="string"?f:JSON.stringify(f))},on:function(f,a){return i[f]||(i[f]=[]),i[f].push(a),function(){let d=i[f],g=d.indexOf(a);g>=0&&d.splice(g,1)}},close:function(f,a){n=!0,r&&(clearTimeout(r),r=null),o&&o.close(f||1e3,a||""),l.status="closed"}};function p(f){if(typeof f!="string")return f;try{return JSON.parse(f)}catch{return f}}function m(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,w()},c),c=Math.min(c*2,e.maxReconnectDelay))}function w(){l.status=u>0?"reconnecting":"connecting";try{o=new WebSocket(t,e.protocols)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let f of i.open)f()},o.onmessage=function(f){let a=p(f.data);for(let d of i.message)d(a)},o.onclose=function(f){l.status="closed",e.onClose(f.code,f.reason);for(let a of i.close)a(f.code,f.reason);n||m()},o.onerror=function(f){e.onError(f);for(let a of i.error)a(f)}}return w(),l}function I(t,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},o=null,n=!1,c=e.reconnectDelay,u=0,r=null,i={message:[],open:[],close:[],error:[]},l={status:"connecting",on:function(a,d){return i[a]||(i[a]=[]),i[a].push(d),function(){let g=i[a],T=g.indexOf(d);T>=0&&g.splice(T,1)}},close:function(){n=!0,r&&(clearTimeout(r),r=null),o&&(o.close(),o=null),l.status="closed"}};function p(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function m(a,d){for(let g of i.message)g(a,d||void 0)}function w(){!e.reconnect||u>=e.maxReconnectAttempts||(u++,l.status="reconnecting",r=setTimeout(function(){r=null,f()},c),c=Math.min(c*2,e.maxReconnectDelay))}function f(){l.status=u>0?"reconnecting":"connecting";try{o=new EventSource(t)}catch{l.status="closed";return}o.onopen=function(){l.status="open",u=0,c=e.reconnectDelay,e.onOpen();for(let a of i.open)a(null)},o.onmessage=function(a){m(p(a.data),null)};for(let a of e.events)o.addEventListener(a,function(d){m(p(d.data),a)});o.onerror=function(a){e.onError(a);for(let d of i.error)d(a);if(o&&o.readyState===2){o=null,l.status="closed",e.onClose();for(let d of i.close)d(null);n||w()}}}return f(),l}var U={set:function(t,s,e){let o="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),o="; expires="+n.toUTCString()}document.cookie=t+"="+(s||"")+o+"; path=/"},get:function(t){let s=t+"=",e=document.cookie.split(";");for(let o=0;o<e.length;o++){let n=e[o];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(t){document.cookie=t+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function j(t,s){let e=document.getElementById("message");if(!e)return;let o=s||"info";e.innerHTML='<div class="alert alert-'+o+' alert-dismissible">'+t+'<button type="button" class="btn-close" data-t4-dismiss="alert">&times;</button></div>'}function B(t,s,e,o){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,c=window.screenTop!==void 0?window.screenTop:window.screenY,u=window.innerWidth||document.documentElement.clientWidth||screen.width,r=window.innerHeight||document.documentElement.clientHeight||screen.height,i=u/window.screen.availWidth,l=(u-e)/2/i+n,p=(r-o)/2/i+c,m=window.open(t,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/i+",height="+o/i+",top="+p+",left="+l);return window.focus&&m&&m.focus(),m}function P(t){if(t.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(t,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function F(t,s,e,o){v(t,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){o&&o(n.data||null,n.errors||void 0)},onError:function(n){o&&o(null,[{message:"GraphQL request failed with status "+n}])}})}function b(t){let s=t.dataset;return s&&s.key?s.key:null}function X(t,s){let e=s.attributes;for(let n=0;n<e.length;n++){let c=e[n];t.getAttribute(c.name)!==c.value&&t.setAttribute(c.name,c.value)}Array.prototype.slice.call(t.attributes).forEach(function(n){s.hasAttribute(n.name)||t.removeAttribute(n.name)})}function G(t,s){let e=s.tagName;e==="INPUT"||e==="TEXTAREA"||e==="SELECT"||(X(t,s),t.children.length||s.children.length?C(t,s):t.innerHTML!==s.innerHTML&&(t.innerHTML=s.innerHTML))}function C(t,s){let e=Array.prototype.slice.call(t.children),o=Array.prototype.slice.call(s.children),n={};e.forEach(function(r){let i=b(r);i&&(n[i]=r)});let c=[];for(let r=0;r<o.length;r++){let i=o[r],l=b(i),p=null;l&&n[l]?p=n[l]:!l&&e[r]&&!b(e[r])&&e[r].tagName===i.tagName&&(p=e[r]),p&&p.tagName===i.tagName?(G(p,i),reused.push(p),c.push(p)):c.push(i)}let u=t.firstElementChild;for(let r=0;r<c.length;r++){let i=c[r];i===u?u=u.nextElementSibling:t.insertBefore(i,u)}e.forEach(function(r){c.indexOf(r)===-1&&r.parentNode===t&&t.removeChild(r)}),reused}function k(t,s){let e=document.createElement("div");if(e.innerHTML=s,!e.children.length||!t.children.length){t.innerHTML=s;return}C(t,e)}function J(t){return/^wss?:\/\//.test(t)?t:(typeof location<"u"&&location.protocol==="https:"?"wss":"ws")+"://"+location.host+t}function N(t,s){return t&&typeof t=="object"?t.type==="live"?s&&t.name&&t.name!==s?null:t.html!=null?String(t.html):null:null:typeof t=="string"?t:null}function S(t){if(typeof document>"u")return;let e=(t||document).querySelectorAll("[data-frond-live]");Array.prototype.slice.call(e).forEach(function(o){if(o.__frondLive)return;o.__frondLive=!0;let n=o.getAttribute("data-mode"),c=o.getAttribute("data-frond-live");if(n==="poll"){let u=o.getAttribute("data-src"),r=(parseInt(o.getAttribute("data-interval"),10)||5)*1e3,i=setInterval(function(){typeof document<"u"&&document.hidden||v(u,{method:"GET",onSuccess:function(l){k(o,typeof l=="string"?l:String(l))}})},r);o.__frondLiveStop=function(){clearInterval(i)}}else if(n==="ws"){let u=M(J(o.getAttribute("data-ws")));u.on("message",function(r){let i=N(r,c);i!==null&&k(o,i)}),o.__frondLiveStop=function(){u.close()}}else n==="sse"&&typeof console<"u"&&console.warn&&console.warn("[frond.live] sse transport is not wired yet (v1 supports poll and ws); block '"+c+"' shows first paint only. Use poll or ws.")})}var L={request:v,load:_,post:R,inject:h,form:x,ws:M,sse:I,live:S,cookie:U,message:j,popup:B,report:P,graphql:F,get token(){return y},set token(t){y=t}};typeof window<"u"&&(window.frond=L,typeof document<"u"&&(document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){S()}):S()));return W(z);})();
2
+ /* Frond v2.2.0 - tina4.com */
@@ -34,8 +34,11 @@ module Tina4
34
34
  fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
35
35
  @static_roots = (project_roots + fallback).freeze
36
36
 
37
- # Shared WebSocket engine for route-based WS handling
37
+ # Shared WebSocket engine for route-based WS handling. Publish it as the
38
+ # process-wide "current" engine so Frond.push_live (and other framework
39
+ # code) can broadcast live-block updates without a threaded reference.
38
40
  @websocket_engine = Tina4::WebSocket.new
41
+ Tina4::WebSocket.current = @websocket_engine
39
42
 
40
43
  # Register the dev-reload WebSocket route (debug mode only) so a browser
41
44
  # handshake to /__dev_reload is accepted and held open by the connection
@@ -111,6 +111,10 @@ module Tina4
111
111
  content = content.gsub("$#{name}", value)
112
112
  end
113
113
 
114
+ # Resolve color functions (lighten/darken/rgba/rgb/mix) — after variable
115
+ # substitution so a $colour arg is already a hex literal (issue #124).
116
+ content = resolve_color_functions(content)
117
+
114
118
  # Handle nesting (basic single-level)
115
119
  content = flatten_nesting(content)
116
120
 
@@ -120,6 +124,109 @@ module Tina4
120
124
  content
121
125
  end
122
126
 
127
+ # Resolve lighten/darken/rgba/rgb/mix color functions. rgba(<hex>, a) is
128
+ # the damaging case (issue #124): the functional rgba() notation cannot
129
+ # take a hex, so rgba(#0f3460, 0.12) is invalid CSS and browsers drop the
130
+ # whole declaration. Convert the hex to its r,g,b components. hex_to_rgb /
131
+ # adjust_lightness are regex-free so they never clobber the match globals.
132
+ def resolve_color_functions(content)
133
+ content = content.gsub(/lighten\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/) do
134
+ adjust_lightness(Regexp.last_match(1).strip, Regexp.last_match(2).strip.chomp("%").to_f / 100)
135
+ end
136
+ content = content.gsub(/darken\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/) do
137
+ adjust_lightness(Regexp.last_match(1).strip, -(Regexp.last_match(2).strip.chomp("%").to_f / 100))
138
+ end
139
+ # rgba(<hex>, <alpha>) — only the two-arg hex form; leave rgba(r,g,b,a).
140
+ content = content.gsub(/rgba\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*([\d.]+)\s*\)/) do
141
+ hex = Regexp.last_match(1)
142
+ alpha = Regexp.last_match(2).strip
143
+ whole = Regexp.last_match(0)
144
+ rgb = hex_to_rgb(hex)
145
+ rgb ? "rgba(#{rgb[0]}, #{rgb[1]}, #{rgb[2]}, #{alpha})" : whole
146
+ end
147
+ content = content.gsub(/rgb\(\s*(#[0-9a-fA-F]{3,8})\s*\)/) do
148
+ hex = Regexp.last_match(1)
149
+ whole = Regexp.last_match(0)
150
+ rgb = hex_to_rgb(hex)
151
+ rgb ? "rgb(#{rgb[0]}, #{rgb[1]}, #{rgb[2]})" : whole
152
+ end
153
+ # mix(<c1>, <c2>[, <weight>]) — Sass weight is c1's proportion (default 50%).
154
+ content.gsub(/mix\(\s*(#[0-9a-fA-F]{3,8})\s*,\s*(#[0-9a-fA-F]{3,8})\s*(?:,\s*([\d.]+%?)\s*)?\)/) do
155
+ hex1 = Regexp.last_match(1)
156
+ hex2 = Regexp.last_match(2)
157
+ weight = Regexp.last_match(3)
158
+ whole = Regexp.last_match(0)
159
+ c1 = hex_to_rgb(hex1)
160
+ c2 = hex_to_rgb(hex2)
161
+ if c1 && c2
162
+ w = weight ? weight.chomp("%").to_f / 100 : 0.5
163
+ mixed = (0..2).map { |i| (c1[i] * w + c2[i] * (1 - w)).round }
164
+ format("#%02x%02x%02x", *mixed)
165
+ else
166
+ whole
167
+ end
168
+ end
169
+ end
170
+
171
+ # Parse a #rgb / #rrggbb hex string into an [r, g, b] int array, or nil.
172
+ # Regex-free (uses Integer()) so it never touches the match globals.
173
+ def hex_to_rgb(color)
174
+ c = color.strip.delete_prefix("#")
175
+ c = c.chars.map { |ch| ch * 2 }.join if c.length == 3
176
+ return nil unless c.length == 6
177
+
178
+ [Integer(c[0, 2], 16), Integer(c[2, 2], 16), Integer(c[4, 2], 16)]
179
+ rescue ArgumentError
180
+ nil
181
+ end
182
+
183
+ # Adjust the HSL lightness of a hex color by `amount` (-1..1), return hex.
184
+ # Truncates (to_i) to match the Python master's int(x*255) byte-for-byte.
185
+ def adjust_lightness(color, amount)
186
+ rgb = hex_to_rgb(color)
187
+ return color if rgb.nil?
188
+
189
+ r, g, b = rgb.map { |v| v / 255.0 }
190
+ max = [r, g, b].max
191
+ min = [r, g, b].min
192
+ l = (max + min) / 2.0
193
+ d = max - min
194
+ h = 0.0
195
+ s = 0.0
196
+ if d != 0.0
197
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
198
+ h = if max == r
199
+ (g - b) / d + (g < b ? 6 : 0)
200
+ elsif max == g
201
+ (b - r) / d + 2
202
+ else
203
+ (r - g) / d + 4
204
+ end
205
+ h /= 6.0
206
+ end
207
+ l = [0.0, [1.0, l + amount].min].max
208
+ if s.zero?
209
+ r = g = b = l
210
+ else
211
+ q = l < 0.5 ? l * (1 + s) : l + s - l * s
212
+ p = 2 * l - q
213
+ r = _hue_to_rgb(p, q, h + 1.0 / 3)
214
+ g = _hue_to_rgb(p, q, h)
215
+ b = _hue_to_rgb(p, q, h - 1.0 / 3)
216
+ end
217
+ format("#%02x%02x%02x", (r * 255).to_i, (g * 255).to_i, (b * 255).to_i)
218
+ end
219
+
220
+ def _hue_to_rgb(p, q, t)
221
+ t += 1 if t < 0
222
+ t -= 1 if t > 1
223
+ return p + (q - p) * 6 * t if t < 1.0 / 6
224
+ return q if t < 1.0 / 2
225
+ return p + (q - p) * (2.0 / 3 - t) * 6 if t < 2.0 / 3
226
+
227
+ p
228
+ end
229
+
123
230
  # Resolve SCSS #{ ... } interpolation. Each #{ expr } is replaced by its
124
231
  # resolved inner text: a $variable inside the braces resolves to its value,
125
232
  # anything else is inlined verbatim (trimmed). Lets a value carry a
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.51"
4
+ VERSION = "3.13.52"
5
5
  end
@@ -135,6 +135,16 @@ module Tina4
135
135
  class WebSocket
136
136
  GUID = WEBSOCKET_GUID
137
137
 
138
+ # Process-wide handle to the live route-serving WebSocket engine. RackApp
139
+ # sets this on construction so framework code (Frond.push_live, background
140
+ # tasks) can broadcast without threading an engine reference through every
141
+ # call site. Mirrors PHP's \Tina4\Server::getInstance(). Nil until a RackApp
142
+ # is built (e.g. in unit specs) — best-effort callers degrade silently.
143
+ @current = nil
144
+ class << self
145
+ attr_accessor :current
146
+ end
147
+
138
148
  attr_reader :connections
139
149
 
140
150
  def initialize
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.51
4
+ version: 3.13.52
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-03 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack