syntropy 0.15.1 → 0.16

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: ef2840797122cef8e0f7f7675c10665c35f60915e091043416ffd0ed70e1cb5c
4
- data.tar.gz: 0d929bfbe0d629879b05c57b7c01c1e8c0ee1af148dc23570c30233f19e81156
3
+ metadata.gz: b90967d51ee11dd83db1237db95b35fb1a2bd9cb36248086bfe62082ddd4c0a5
4
+ data.tar.gz: 187eb2adc5a21df0f8d0400ef54609300658df9e3ba371686c1007d4744c0667
5
5
  SHA512:
6
- metadata.gz: 6375f3fb5cbab65d6b710b613c32d0eb9ebb714880f0eb3921fd0b07da9219e0ab94faec2398ae2f33a21fa4f21a8135416faf5b6f2ad0721a99afa067b854f7
7
- data.tar.gz: 715274f642dc3b0db72b0ec37526f26221b56930e19eb324f804408b18552a5c465d59f313cd8329a24a9caa0aa05abaa6907d523f15e1e7061e3e9e1bf1762c
6
+ metadata.gz: 1c0aff28315990526db9d48c1d4dba164d9944f93bdbcb671fdd6526a2f77eac28fa89cc2a99e68ce6bc5e55db85582270b3ea6032456c5e5d2fe9847150e3ae
7
+ data.tar.gz: 10d5ceb7568d22a07d89e13cca104aeeeea94e62a03c778a72c3da360b8a21ef466038d4d872b75ae00b1634c2dfffc8931e4a61695e95e99c78a51bfcd5c643
data/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ # 0.16 2025-09-11
2
+
3
+ - `syntropy` script:
4
+ - Remove trailing slash for root dir in syntropy script
5
+ - Rename `-w/--watch` option to `-d/--dev` for development mode
6
+ - Fix `--mount` option
7
+ - Add builtin `/.syntropy` applet for builtin features:
8
+ - auto refresh for web pages
9
+ - JSON API
10
+ - Template debugging frontend tools
11
+ - ping route
12
+ - home page with links to examples
13
+ - Implement applet mounting and loading
14
+ - Remove Papercraft dependency
15
+ - Add support for P2 XML templates, using `#template_xml`
16
+ - Update P2, TP2
17
+
1
18
  # 0.15 2025-08-31
2
19
 
3
20
  - Implement invalidation of reverse dependencies on module file change
data/TODO.md CHANGED
@@ -22,6 +22,8 @@
22
22
  article.layout #=>
23
23
  article.render_proc #=> (load layout, apply article)
24
24
  article.render #=> (render to HTML)
25
+
26
+ # there should also be methods for creating, updating and deleting of articles/items.
25
27
  ...
26
28
  ```
27
29
 
@@ -30,6 +32,149 @@
30
32
  - [ ] support for caching headers
31
33
  - [ ] add `Request#render_static_file(route, fn)
32
34
 
35
+ - [ ] Serving of built-in assets (mostly JS)
36
+ - [ ] JS lib for RPC API
37
+
38
+ ## Missing for a first public release
39
+
40
+ - [ ] Logo
41
+ - [ ] Website
42
+ - [ ] Frontend part of RPC API
43
+ - [v] Auto-refresh page when file changes
44
+ - [ ] Examples
45
+ - [ ] Reactive app - counter or some other simple app showing interaction with server
46
+ - [ ] ?
47
+
48
+ ## Counter example
49
+
50
+ Here's a react component (from https://fresh.deno.dev/):
51
+
52
+ ```jsx
53
+ // islands/Counter.tsx
54
+ import { useSignal } from "@preact/signals";
55
+
56
+ export default function Counter(props) {
57
+ const count = useSignal(props.start);
58
+
59
+ return (
60
+ <div>
61
+ <h3>Interactive island</h3>
62
+ <p>The server supplied the initial value of {props.start}.</p>
63
+ <div>
64
+ <button onClick={() => count.value -= 1}>-</button>
65
+ <div>{count}</div>
66
+ <button onClick={() => count.value += 1}>+</button>
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+ ```
72
+
73
+ How do we do this with Syntropy? Can we make a component that does the
74
+ templating and the reactivity in a single file? Can we wrap reactivity in a
75
+ component that has its own state? And where does the state live?
76
+
77
+ ```ruby
78
+ class Counter < Syntropy::Component
79
+ def initialize(start:, **props)
80
+ @count = reactive()
81
+ end
82
+
83
+ def template
84
+ div {
85
+ h3 'Interactive island'
86
+ p "The server supplied the initial value of #props[:start]}"
87
+ div {
88
+ button '-', on_click: -> { @count.value -= 1 }
89
+ div @count.value
90
+ button '+', on_click: -> { @count.value += 1 }
91
+ }
92
+ }
93
+ end
94
+ end
95
+ ```
96
+
97
+ Hmm, don't know if the complexity is worth it. It's an abstraction that's very
98
+ costly - both in terms of complexity of computation, and in terms of having a
99
+ clear mental model of what's happening under the hood.
100
+
101
+ I think a more logical approach is to stay with well-defined boundaries between
102
+ computation on the frontend and computation on the backend, and a having a clear
103
+ understanding of where things happen:
104
+
105
+ ```ruby
106
+ class Counter < Syntropy::Component
107
+ def incr
108
+ update(value: @props[:value] + 1)
109
+ end
110
+
111
+ def decr
112
+ update(value: @props[:value] - 1)
113
+ end
114
+
115
+ def template
116
+ div {
117
+ h3 'Interactive island'
118
+ div {
119
+ button '-', syn_click: 'decr'
120
+ div @props[:value]
121
+ button '+', syn_click: 'incr'
122
+ }
123
+ }
124
+ end
125
+ end
126
+ ```
127
+
128
+ Now, we can do all kinds of wrapping with scripts and ids and stuff to make this
129
+ work, but still, it would be preferable for the interactivity to be expressed in
130
+ JS. Maybe we just need a way to include a script that acts on the local HTML
131
+ code. How can we do this without writing a web component etc?
132
+
133
+ One way is to assign a random id to the template, then have a script that works
134
+ on it locally.
135
+
136
+ ```ruby
137
+ export template { |**props|
138
+ id = SecureRandom.hex(4)
139
+ div(id: id) {
140
+ h3 'Interactive island'
141
+ div {
142
+ button '-', syn_action: "decr"
143
+ div props[:value], syn_value: "value"
144
+ button '+', syn_action: "incr"
145
+ }
146
+ script <<~JS
147
+ const state = { value: #{props[:value]} }
148
+ const root = document.querySelector('##{id}')
149
+ const decr = root.querySelector('[syn-action="decr"]')
150
+ const incr = root.querySelector('[syn-action="incr"]')
151
+ const value = root.querySelector('[syn-value="value"]')
152
+ const updateValue = (v) => { state.value = v; value.innerText = String(v) }
153
+
154
+ decr.addEventListener('click', (e) => { updateValue(state.value - 1) })
155
+ incr.addEventListener('click', (e) => { updateValue(state.value + 1) })
156
+ JS
157
+ }
158
+ }
159
+ ```
160
+
161
+ How can we make this less verbose, less painful, less error-prone?
162
+
163
+ One way is to say - we don't worry about this on the backend, we just write
164
+ normal JS for the frontend and forget about the whole thing. Another way is to
165
+ provide a set of tools for making this less painful:
166
+
167
+ - Add some fancier abstractions on top of the JS RPC lib
168
+ - Add some template extensions that inject JS into the generated HTML
169
+
170
+ ## Testing facilities
171
+
172
+ - What do we need to test?
173
+ - Routes
174
+ - Route responses
175
+ - Changes to state / DB
176
+ -
177
+
33
178
  ## Support for applets
34
179
 
35
180
  - can be implemented as separate gems
data/bin/syntropy CHANGED
@@ -7,7 +7,8 @@ require 'optparse'
7
7
  env = {
8
8
  mount_path: '/',
9
9
  banner: Syntropy::BANNER,
10
- logger: true
10
+ logger: true,
11
+ builtin_applet_path: '/.syntropy'
11
12
  }
12
13
 
13
14
  parser = OptionParser.new do |o|
@@ -24,7 +25,8 @@ parser = OptionParser.new do |o|
24
25
  env[:logger] = nil
25
26
  end
26
27
 
27
- o.on('-w', '--watch', 'Watch for changed files') do
28
+ o.on('-d', '--dev', 'Development mode') do
29
+ env[:dev_mode] = true
28
30
  env[:watch_files] = 0.1
29
31
  end
30
32
 
@@ -33,8 +35,14 @@ parser = OptionParser.new do |o|
33
35
  exit
34
36
  end
35
37
 
36
- o.on('-m', '--mount', 'Set mount path (default: /)') do
37
- env[:mount_path] = it
38
+ o.on('-m', '--mount PATH', 'Set mount path (default: /)') do |path|
39
+ p mount: path
40
+ env[:mount_path] = path
41
+ env[:builtin_applet_path] = File.join(path, '.syntropy')
42
+ end
43
+
44
+ o.on('--no-builtin-applet', 'Do not mount builtin applet') do
45
+ env[:builtin_applet_path] = nil
38
46
  end
39
47
 
40
48
  o.on('-v', '--version', 'Show version') do
@@ -54,7 +62,7 @@ rescue StandardError => e
54
62
  exit
55
63
  end
56
64
 
57
- env[:root_dir] = ARGV.shift || '.'
65
+ env[:root_dir] = (ARGV.shift || '.').gsub(/\/$/, '')
58
66
 
59
67
  if !File.directory?(env[:root_dir])
60
68
  puts "#{File.expand_path(env[:root_dir])} Not a directory"
@@ -70,6 +78,7 @@ env[:machine] = Syntropy.machine = UM.new
70
78
  env[:logger] = env[:logger] && TP2::Logger.new(env[:machine], **env)
71
79
 
72
80
  require 'syntropy/version'
81
+ require 'syntropy/dev_mode' if env[:dev_mode]
73
82
 
74
83
  env[:logger]&.info(message: "Running Syntropy version #{Syntropy::VERSION}")
75
84
  app = Syntropy::App.load(env)
data/examples/card.rb ADDED
@@ -0,0 +1,12 @@
1
+ export template {
2
+ card {
3
+ h2 'Some card'
4
+ h3 'Some subtitle'
5
+
6
+ markdown <<~MARKDOWN
7
+ - Foo
8
+ - Bar
9
+ - Baz
10
+ MARKDOWN
11
+ }
12
+ }
@@ -0,0 +1,20 @@
1
+ import JSONAPI from '/.syntropy/json_api.js'
2
+
3
+ (() => {
4
+ const api = new JSONAPI('/counter_api');
5
+
6
+ const value = document.querySelector('#value');
7
+ const decr = document.querySelector('#decr');
8
+ const incr = document.querySelector('#incr');
9
+
10
+ decr.addEventListener('click', async () => {
11
+ const result = await api.post('decr')
12
+ value.innerText = String(result);
13
+ });
14
+
15
+ incr.addEventListener('click', async () => {
16
+ const result = await api.post('incr')
17
+ value.innerText = String(result);
18
+ });
19
+
20
+ })()
@@ -0,0 +1,23 @@
1
+ CounterAPI = import 'counter_api'
2
+
3
+ export template {
4
+ html5 {
5
+ body {
6
+ p { a '< Home', href: '/' }
7
+
8
+ h1 'Counter'
9
+
10
+ div {
11
+ button '-', id: 'decr'
12
+ value CounterAPI.value, id: 'value'
13
+ button '+', id: 'incr'
14
+ }
15
+ }
16
+ script src: '/counter.js', type: 'module'
17
+ style <<~CSS
18
+ div { font-weight: bold; font-size: 1.3em }
19
+ value { display: inline-block; padding: 0 1em; color: blue; width: 1em }
20
+ CSS
21
+ auto_refresh_watch!
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CounterAPI < Syntropy::JSONAPI
4
+ def initialize(env)
5
+ @env = env
6
+ @value = env[:counter_value] || 0
7
+ end
8
+
9
+ def value(req = nil)
10
+ @value
11
+ end
12
+
13
+ def incr!(req)
14
+ @value += 1
15
+ end
16
+
17
+ def decr!(req)
18
+ @value -= 1
19
+ end
20
+ end
21
+
22
+ export CounterAPI
File without changes
data/examples/index.md ADDED
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Syntropy Examples
3
+ ---
4
+
5
+ # Syntropy Examples
6
+
7
+ - [Template composition](/templates)
8
+ - [JSON API](/counter)
@@ -0,0 +1,40 @@
1
+ Card = import 'card'
2
+
3
+ export template {
4
+ html5 {
5
+ head {
6
+ style {
7
+ raw <<~CSS
8
+ div {
9
+ display: grid;
10
+ grid-template-columns: 1fr 1fr;
11
+ }
12
+ span.foo {
13
+ color: white;
14
+ background-color: blue;
15
+ padding: 1em;
16
+ }
17
+ span.bar {
18
+ color: white;
19
+ background-color: green;
20
+ padding: 1em;
21
+ }
22
+ CSS
23
+ }
24
+ }
25
+ body {
26
+ p { a '< Home', href: '/' }
27
+
28
+ h1 'Testing'
29
+
30
+ div {
31
+ span 'foo', class: 'foo'
32
+ span 'bar', class: 'bar'
33
+ }
34
+
35
+ Card()
36
+ }
37
+ auto_refresh_watch!
38
+ debug_template!
39
+ }
40
+ }
data/lib/syntropy/app.rb CHANGED
@@ -43,6 +43,7 @@ module Syntropy
43
43
  @root_dir = File.expand_path(env[:root_dir])
44
44
  @mount_path = env[:mount_path]
45
45
  @env = env
46
+ @logger = env[:logger]
46
47
 
47
48
  @module_loader = Syntropy::ModuleLoader.new(app: self, **env)
48
49
  setup_routing_tree
@@ -70,15 +71,22 @@ module Syntropy
70
71
  proc = route[:proc] ||= compute_route_proc(route)
71
72
  proc.(req)
72
73
  rescue StandardError => e
73
- @env[:logger]&.error(
74
- message: "Error while serving request",
74
+ @logger&.error(
75
+ message: "Error while serving request: #{e.message}",
75
76
  method: req.method,
76
- path: req.path
77
+ path: req.path,
78
+ error: e
77
79
  )
78
80
  error_handler = get_error_handler(route)
79
81
  error_handler.(req, e)
80
82
  end
81
83
 
84
+ def route(path, params = {}, compute_proc: false)
85
+ route = @router_proc.(path, params)
86
+ route[:proc] ||= compute_route_proc(route) if compute_proc
87
+ route
88
+ end
89
+
82
90
  private
83
91
 
84
92
  # Instantiates a routing tree with the app settings, and generates a router
@@ -89,9 +97,16 @@ module Syntropy
89
97
  @routing_tree = Syntropy::RoutingTree.new(
90
98
  root_dir: @root_dir, mount_path: @mount_path, **@env
91
99
  )
100
+ mount_builtin_applet if @env[:builtin_applet_path]
92
101
  @router_proc = @routing_tree.router_proc
93
102
  end
94
103
 
104
+ def mount_builtin_applet
105
+ path = @env[:builtin_applet_path]
106
+ @builtin_applet ||= Syntropy.builtin_applet(@env, mount_path: path)
107
+ @routing_tree.mount_applet(path, @builtin_applet)
108
+ end
109
+
95
110
  # Computes the route proc for the given route, wrapping it in hooks found up
96
111
  # the routing tree.
97
112
  #
@@ -146,9 +161,9 @@ module Syntropy
146
161
  if (layout = atts[:layout])
147
162
  route[:applied_layouts] ||= {}
148
163
  proc = route[:applied_layouts][layout] ||= markdown_layout_proc(layout)
149
- html = proc.render(md: md, **atts)
164
+ html = proc.render(md:, **atts)
150
165
  else
151
- html = P2.markdown(md)
166
+ html = default_markdown_layout_proc.render(md:, **atts)
152
167
  end
153
168
  html
154
169
  end
@@ -160,9 +175,22 @@ module Syntropy
160
175
  @layouts[layout] = template.apply { |md:, **| markdown(md) }
161
176
  end
162
177
 
178
+ def default_markdown_layout_proc
179
+ @default_markdown_layout ||= ->(md:, **atts) {
180
+ html5 {
181
+ head {
182
+ title atts[:title]
183
+ }
184
+ body {
185
+ markdown md
186
+ auto_refresh_watch!
187
+ }
188
+ }
189
+ }
190
+ end
191
+
163
192
  def module_route_proc(route)
164
193
  ref = @routing_tree.fn_to_rel_path(route[:target][:fn])
165
- # ref = route[:target][:fn].sub(@mount_path, '')
166
194
  mod = @module_loader.load(ref)
167
195
  compute_module_proc(mod)
168
196
  end
@@ -171,31 +199,23 @@ module Syntropy
171
199
  case mod
172
200
  when P2::Template
173
201
  p2_template_proc(mod)
174
- when Papercraft::Template
175
- papercraft_template_proc(mod)
176
202
  else
177
203
  mod
178
204
  end
179
205
  end
180
206
 
181
207
  def p2_template_proc(template)
208
+ xml_mode = template.mode == :xml
182
209
  template = template.proc
183
- headers = { 'Content-Type' => 'text/html' }
210
+ mime_type = xml_mode ? 'text/xml; charset=UTF-8' : 'text/html; charset=UTF-8'
211
+ headers = { 'Content-Type' => mime_type }
184
212
 
185
- ->(req) {
186
- req.respond_by_http_method(
187
- 'head' => [nil, headers],
188
- 'get' => -> { [template.render, headers] }
189
- )
190
- }
191
- end
213
+ get_proc = xml_mode ? -> { [template.render_xml, headers] } : -> { [template.render, headers] }
192
214
 
193
- def papercraft_template_proc(template)
194
- headers = { 'Content-Type' => template.mime_type }
195
215
  ->(req) {
196
216
  req.respond_by_http_method(
197
217
  'head' => [nil, headers],
198
- 'get' => -> { [template.render, headers] }
218
+ 'get' => get_proc
199
219
  )
200
220
  }
201
221
  end
@@ -232,7 +252,7 @@ module Syntropy
232
252
  DEFAULT_ERROR_HANDLER = ->(req, err) {
233
253
  msg = err.message
234
254
  msg = nil if msg.empty? || (req.method == 'head')
235
- req.respond(msg, ':status' => Syntropy::Error.http_status(err))
255
+ req.respond(msg, ':status' => Syntropy::Error.http_status(err)) rescue nil
236
256
  }
237
257
 
238
258
  # Returns an error handler for the given route. If route is nil, looks up
@@ -286,7 +306,7 @@ module Syntropy
286
306
  # setup tasks
287
307
  @machine.sleep 0.2
288
308
  route_count = @routing_tree.static_map.size + @routing_tree.dynamic_map.size
289
- @env[:logger]&.info(
309
+ @logger&.info(
290
310
  message: "Serving from #{@root_dir} (#{route_count} routes found)"
291
311
  )
292
312
 
@@ -302,7 +322,7 @@ module Syntropy
302
322
  wf = @env[:watch_files]
303
323
  period = wf.is_a?(Numeric) ? wf : 0.1
304
324
  Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
305
- @env[:logger]&.info(message: "File change detected", fn: fn)
325
+ @logger&.info(message: 'File change detected', fn: fn)
306
326
  @module_loader.invalidate_fn(fn)
307
327
  debounce_file_change
308
328
  end
@@ -324,7 +344,23 @@ module Syntropy
324
344
  @machine.sleep(0.1)
325
345
  setup_routing_tree
326
346
  @routing_tree_reloader = nil
347
+ signal_auto_refresh_watchers!
327
348
  end
328
349
  end
350
+
351
+ def signal_auto_refresh_watchers!
352
+ return if !@builtin_applet
353
+
354
+ watcher_route_path = File.join(@env[:builtin_applet_path], 'auto_refresh/watch.sse')
355
+ watcher_route = @builtin_applet.route(watcher_route_path, compute_proc: true)
356
+
357
+ watcher_mod = watcher_route[:proc]
358
+ watcher_mod.signal!
359
+ rescue => e
360
+ @logger&.error(
361
+ message: 'Unexpected error while signalling auto refresh watcher',
362
+ error: e
363
+ )
364
+ end
329
365
  end
330
366
  end
@@ -0,0 +1,12 @@
1
+ (() => {
2
+ const jsURL = import.meta.url;
3
+ const sseURL = jsURL.replace(/\.js$/, '.sse');
4
+ const eventSource = new EventSource(sseURL);
5
+
6
+ eventSource.addEventListener('message', (msg) => {
7
+ if (msg.data != '') window.location.reload();
8
+ })
9
+ eventSource.addEventListener('error', () => {
10
+ console.log(`Failed to connect to auto refresh watcher (${sseURL})`);
11
+ })
12
+ })()
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This module implements an SSE (server-sent events) route, that emits a message
4
+ # to the client when a file has been changed. The module is signalled by the
5
+ # running app whenever a file change, when in watch mode (`-w`). This route
6
+ # resides by default at `/.syntropy/auto_refresh/watch.sse`.
7
+ #
8
+ # The complementary client-side party is implemented in a small JS script
9
+ # residing by default at `/.syntropy/auto_refresh/watch.js`.
10
+
11
+ # Returns a hash holding references to queues for ongoing `watch.sse` requests.
12
+ def watchers
13
+ @watchers ||= {}
14
+ end
15
+
16
+ # Signals a file change by pushing to all watcher queues.
17
+ def signal!
18
+ watchers.each_key { @machine.push(it, true) }
19
+ end
20
+
21
+ # Handles incoming requests to the `watch.sse` route. Adds a queue to the list
22
+ # of watchers, and waits for the queue to be signalled. In the absence of file
23
+ # change, a timeout occurs after one minute, and the request is terminated.
24
+ def call(req)
25
+ queue = UM::Queue.new
26
+ watchers[queue] = true
27
+
28
+ req.send_headers('Content-Type' => 'text/event-stream')
29
+ req.send_chunk("data: \n\n")
30
+ @machine.timeout(60, Timeout::Error) do
31
+ @machine.shift(queue)
32
+ req.send_chunk("data: refresh\n\n")
33
+ end
34
+ req.send_chunk("retry: 0\n\n", done: true) rescue nil
35
+ rescue Timeout::Error
36
+ req.send_chunk("retry: 0\n\n", done: true) rescue nil
37
+ rescue SystemCallError
38
+ # ignore
39
+ rescue => e
40
+ @logger&.error(
41
+ message: 'Unexpected error encountered while serving auto refresh watcher',
42
+ error: e
43
+ )
44
+ req.finish rescue nil
45
+ ensure
46
+ watchers.delete(queue)
47
+ end
48
+
49
+ export self
@@ -0,0 +1,61 @@
1
+ debug-attachment {
2
+ position: relative;
3
+ /* display: block; */
4
+ top: 0;
5
+ right: 0;
6
+ bottom: 0;
7
+ left: 0;
8
+ /* opacity: 0; */
9
+ width: 100%;
10
+ height: 100%;
11
+ }
12
+
13
+ debug-label {
14
+ display: block;
15
+ position: absolute;
16
+ top: -12px;
17
+ left: 4px;
18
+ background: rgba(0, 0, 0, 0.8);
19
+ color: white;
20
+ padding: 2px 6px;
21
+ font-size: 11px;
22
+ font-family: 'Segoe UI', Roboto, monospace;
23
+ font-weight: 500;
24
+ border-radius: 3px;
25
+ display: block;
26
+ z-index: 1001;
27
+ cursor: pointer;
28
+ /* transition: all 0.2s ease; */
29
+ white-space: nowrap;
30
+ line-height: 1.2;
31
+
32
+ a, a:hover, a:visited {
33
+ color: white;
34
+ }
35
+
36
+ &:hover {
37
+ display: block;
38
+ }
39
+
40
+ &.fn {
41
+ display: block;
42
+ }
43
+ }
44
+
45
+ body *:not(script)[data-syntropy-level]:hover {
46
+ outline: #13630c dotted 2px;
47
+ outline-offset: 2px;
48
+
49
+ /* debug-label {
50
+ display: block;
51
+ } */
52
+ }
53
+
54
+ body[data-syntropy-level="1"], body *:not(script)[data-syntropy-level="1"] {
55
+ outline: #2357aa dotted 2px;
56
+ outline-offset: 2px;
57
+ }
58
+
59
+ card {
60
+ display: block;
61
+ }