sandals 0.1.1 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +81 -0
- data/lib/sandals/action_error.rb +1 -9
- data/lib/sandals/application.rb +12 -5
- data/lib/sandals/cli.rb +34 -3
- data/lib/sandals/code_reloader.rb +2 -32
- data/lib/sandals/error_details.rb +67 -0
- data/lib/sandals/html_renderer.rb +290 -60
- data/lib/sandals/style.rb +82 -0
- data/lib/sandals/version.rb +1 -1
- data/lib/sandals/view.rb +42 -18
- data/lib/sandals.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 98c7f9a7ec9e5802a3ba038d5dfa807c3c93169ba1a3722d20c59efdf807a8cd
|
|
4
|
+
data.tar.gz: 644f49cd97d3002639c261034af166c485bd90e8947def34a2bcb219568f7f0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2298067696337102444a3c32fc6414a1d498ab1fae70967c1ba40bc45fc83e55c76cfe5010fbad45139a663514b97ea1d4e645c1a5a487aff46f11fdfc35f93e
|
|
7
|
+
data.tar.gz: 86458ee36df1a77367e726a309f584565e90932c745e9c4594f9d61fbdb11685b8b440455bb575b4020e08e0091c1976f6752182d13be58040bcf48c9d0bc3c1
|
data/README.md
CHANGED
|
@@ -75,6 +75,28 @@ ruby hello_test.rb
|
|
|
75
75
|
The test fills in `"Your name"` and verifies that the greeting appears. It uses
|
|
76
76
|
Minitest, which is installed with Sandals.
|
|
77
77
|
|
|
78
|
+
## Startup errors
|
|
79
|
+
|
|
80
|
+
Sandals reports startup failures with the error type, application file, and
|
|
81
|
+
line number instead of printing an internal framework backtrace:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
sandals: SyntaxError at app.rb:12
|
|
85
|
+
app.rb:12: syntax error, unexpected end-of-input
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Missing dependencies are reported in the same way:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
sandals: LoadError at app.rb:1
|
|
92
|
+
cannot load such file -- sqlite3
|
|
93
|
+
Add the missing dependency to the app and try again.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Sandals also evaluates the initial view before opening the server, so an
|
|
97
|
+
exception during the first render points to the relevant line in the
|
|
98
|
+
application.
|
|
99
|
+
|
|
78
100
|
## The experience
|
|
79
101
|
|
|
80
102
|
An application should normally fit in a single file:
|
|
@@ -141,6 +163,55 @@ end
|
|
|
141
163
|
`para` renders as a paragraph and can combine strings with `strong` for
|
|
142
164
|
important content and `em` for emphasis.
|
|
143
165
|
|
|
166
|
+
## Styling
|
|
167
|
+
|
|
168
|
+
Sandals has a neutral default appearance, but an application can establish its
|
|
169
|
+
own personality in Ruby. Use `style` outside the view to configure defaults:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
Sandals.app title: "Notes" do
|
|
173
|
+
style :app, background: "#fffaf3", color: "#292524"
|
|
174
|
+
style :stack, gap: 18
|
|
175
|
+
style :title, color: "#9a3412", size: 42
|
|
176
|
+
style :button,
|
|
177
|
+
background: "#ea580c",
|
|
178
|
+
color: "white",
|
|
179
|
+
border: "1px solid #c2410c",
|
|
180
|
+
radius: 10
|
|
181
|
+
|
|
182
|
+
view do
|
|
183
|
+
stack padding: 24 do
|
|
184
|
+
title "Notes"
|
|
185
|
+
para "A small app with its own visual character."
|
|
186
|
+
button "Delete", background: "#b91c1c"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
An option on an individual element overrides the application default. The
|
|
193
|
+
resolution order is:
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
Sandals defaults → application defaults → element options
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The first styling API supports `app`, `stack`, `flow`, `title`, `subtitle`,
|
|
200
|
+
`para`, and `button`. Available properties are `background`, `color`, `border`,
|
|
201
|
+
`radius`, `size`, `weight`, `width`, `padding`, `margin`, `gap`, and `align`.
|
|
202
|
+
Numeric dimensions use pixels, while strings can include their own CSS unit:
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
style :title, size: "2.5rem"
|
|
206
|
+
stack width: "100%", gap: 16 do
|
|
207
|
+
# ...
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Unknown elements and properties raise an error instead of silently producing
|
|
212
|
+
invalid styles. `form` remains semantic: use a surrounding `stack` or `flow`
|
|
213
|
+
to control its composition.
|
|
214
|
+
|
|
144
215
|
`image` displays an image from a path or URL. Alternative text is optional, but
|
|
145
216
|
should describe the image whenever it carries meaningful content:
|
|
146
217
|
|
|
@@ -527,6 +598,16 @@ file and recovers automatically when the error is fixed.
|
|
|
527
598
|
The current version watches only the file passed to `sandals run`, not files
|
|
528
599
|
loaded indirectly through `require`.
|
|
529
600
|
|
|
601
|
+
## Connection recovery
|
|
602
|
+
|
|
603
|
+
If the local Sandals process stops while the application is open, the browser
|
|
604
|
+
shows a persistent “Connection lost” message and releases any pending buttons.
|
|
605
|
+
It retries the connection once per second while the tab is visible.
|
|
606
|
+
|
|
607
|
+
When Sandals is available again, the browser reloads the application
|
|
608
|
+
automatically. Events entered while disconnected are discarded because the
|
|
609
|
+
restarted process creates a new Ruby session.
|
|
610
|
+
|
|
530
611
|
## Mental model
|
|
531
612
|
|
|
532
613
|
```text
|
data/lib/sandals/action_error.rb
CHANGED
|
@@ -17,15 +17,7 @@ module Sandals
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def location
|
|
20
|
-
|
|
21
|
-
relevant_location = locations.find do |candidate|
|
|
22
|
-
!candidate.absolute_path.to_s.include?("/lib/sandals/")
|
|
23
|
-
end || locations.first
|
|
24
|
-
return "Location unavailable" unless relevant_location
|
|
25
|
-
|
|
26
|
-
path = relevant_location.absolute_path || relevant_location.path
|
|
27
|
-
path = path.delete_prefix("#{Dir.pwd}/")
|
|
28
|
-
"#{path}:#{relevant_location.lineno}"
|
|
20
|
+
ErrorDetails.for(original_error).fetch(:location)
|
|
29
21
|
end
|
|
30
22
|
end
|
|
31
23
|
|
data/lib/sandals/application.rb
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
module Sandals
|
|
4
4
|
class Application
|
|
5
|
-
attr_reader :refresh_interval
|
|
5
|
+
attr_reader :refresh_interval, :styles
|
|
6
6
|
|
|
7
7
|
def initialize(title:, &definition)
|
|
8
8
|
@title = title
|
|
9
9
|
@definition = definition
|
|
10
10
|
@refresh_interval = nil
|
|
11
|
+
@styles = {}
|
|
11
12
|
@view_definition = nil
|
|
12
13
|
@snapshots = SnapshotHistory.new
|
|
13
14
|
instance_eval(&definition)
|
|
@@ -32,18 +33,24 @@ module Sandals
|
|
|
32
33
|
@refresh_interval = interval
|
|
33
34
|
end
|
|
34
35
|
|
|
36
|
+
def style(element, **properties)
|
|
37
|
+
element = Style.validate_element!(element)
|
|
38
|
+
normalized = Style.normalize(element, properties)
|
|
39
|
+
@styles[element] = (@styles.fetch(element, {})).merge(normalized).freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
35
42
|
def select(label, **options, &action)
|
|
36
43
|
@current_view.select(label, **options, &action)
|
|
37
44
|
end
|
|
38
45
|
|
|
39
|
-
def title(*arguments)
|
|
40
|
-
return @title if arguments.empty?
|
|
46
|
+
def title(*arguments, **options)
|
|
47
|
+
return @title if arguments.empty? && options.empty?
|
|
41
48
|
|
|
42
|
-
@current_view.title(*arguments)
|
|
49
|
+
@current_view.title(*arguments, **options)
|
|
43
50
|
end
|
|
44
51
|
|
|
45
52
|
def render_view
|
|
46
|
-
@current_view = View.new
|
|
53
|
+
@current_view = View.new(styles:)
|
|
47
54
|
instance_eval(&view)
|
|
48
55
|
@current_view
|
|
49
56
|
ensure
|
data/lib/sandals/cli.rb
CHANGED
|
@@ -5,6 +5,16 @@ require_relative "../sandals"
|
|
|
5
5
|
|
|
6
6
|
module Sandals
|
|
7
7
|
class CLI
|
|
8
|
+
class InitialAppError < StandardError
|
|
9
|
+
attr_reader :original_error, :path
|
|
10
|
+
|
|
11
|
+
def initialize(original_error, path)
|
|
12
|
+
@original_error = original_error
|
|
13
|
+
@path = path
|
|
14
|
+
super(original_error.message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
8
18
|
def self.start(arguments)
|
|
9
19
|
new(arguments).run
|
|
10
20
|
end
|
|
@@ -20,11 +30,13 @@ module Sandals
|
|
|
20
30
|
when "new" then new_app
|
|
21
31
|
else usage
|
|
22
32
|
end
|
|
23
|
-
rescue
|
|
24
|
-
|
|
33
|
+
rescue InitialAppError => error
|
|
34
|
+
report_initial_app_error(error)
|
|
25
35
|
rescue StandardError => error
|
|
26
36
|
warn "sandals: #{error.message}"
|
|
27
37
|
1
|
|
38
|
+
rescue Interrupt
|
|
39
|
+
0
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
private
|
|
@@ -74,7 +86,7 @@ module Sandals
|
|
|
74
86
|
parser.parse!(@arguments)
|
|
75
87
|
|
|
76
88
|
path = resolve_app(@arguments.shift || "app")
|
|
77
|
-
application =
|
|
89
|
+
application = load_initial_application(path)
|
|
78
90
|
server = Server.new(application, port: options[:port], reload_path: path)
|
|
79
91
|
|
|
80
92
|
trap("INT") { server.stop }
|
|
@@ -85,6 +97,25 @@ module Sandals
|
|
|
85
97
|
0
|
|
86
98
|
end
|
|
87
99
|
|
|
100
|
+
def load_initial_application(path)
|
|
101
|
+
application = Sandals.load(path)
|
|
102
|
+
application.render(development_reload: true)
|
|
103
|
+
application
|
|
104
|
+
rescue SyntaxError, LoadError, StandardError => error
|
|
105
|
+
raise InitialAppError.new(error, File.expand_path(path))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def report_initial_app_error(error)
|
|
109
|
+
original = error.original_error
|
|
110
|
+
details = ErrorDetails.for(original, fallback_path: error.path)
|
|
111
|
+
warn "sandals: #{details.fetch(:class)} at #{details.fetch(:location)}"
|
|
112
|
+
warn details.fetch(:message)
|
|
113
|
+
if original.is_a?(LoadError)
|
|
114
|
+
warn "Add the missing dependency to the app and try again."
|
|
115
|
+
end
|
|
116
|
+
1
|
|
117
|
+
end
|
|
118
|
+
|
|
88
119
|
def resolve_app(name)
|
|
89
120
|
candidates = [name]
|
|
90
121
|
candidates << "#{name}.rb" unless name.end_with?(".rb")
|
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Sandals
|
|
4
4
|
class CodeReloader
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
Result = Data.define(:version, :application, :error, :changed)
|
|
5
|
+
Result = Struct.new(:version, :application, :error, :changed)
|
|
8
6
|
|
|
9
7
|
attr_reader :path
|
|
10
8
|
|
|
@@ -46,35 +44,7 @@ module Sandals
|
|
|
46
44
|
end
|
|
47
45
|
|
|
48
46
|
def error_details(error)
|
|
49
|
-
|
|
50
|
-
class: error.class.to_s,
|
|
51
|
-
message: clean_message(error),
|
|
52
|
-
location: error_location(error)
|
|
53
|
-
}
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def error_location(error)
|
|
57
|
-
syntax_location =
|
|
58
|
-
clean_message(error).lines.first&.match(/\A(.+?):(\d+):/) if
|
|
59
|
-
error.is_a?(SyntaxError)
|
|
60
|
-
if syntax_location
|
|
61
|
-
source_path = syntax_location[1].delete_prefix("#{Dir.pwd}/")
|
|
62
|
-
return "#{source_path}:#{syntax_location[2]}"
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
locations = error.backtrace_locations || []
|
|
66
|
-
location = locations.find do |candidate|
|
|
67
|
-
!candidate.absolute_path.to_s.include?("/lib/sandals/")
|
|
68
|
-
end || locations.first
|
|
69
|
-
return path unless location
|
|
70
|
-
|
|
71
|
-
source_path = location.absolute_path || location.path
|
|
72
|
-
source_path = source_path.delete_prefix("#{Dir.pwd}/")
|
|
73
|
-
"#{source_path}:#{location.lineno}"
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def clean_message(error)
|
|
77
|
-
error.message.gsub(ANSI_ESCAPE, "")
|
|
47
|
+
ErrorDetails.for(error, fallback_path: path)
|
|
78
48
|
end
|
|
79
49
|
end
|
|
80
50
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class ErrorDetails
|
|
5
|
+
ANSI_ESCAPE = /\e\[[0-9;]*m/
|
|
6
|
+
|
|
7
|
+
def self.for(error, fallback_path: nil)
|
|
8
|
+
new(error, fallback_path:).to_h
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(error, fallback_path:)
|
|
12
|
+
@error = error
|
|
13
|
+
@fallback_path = fallback_path
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{
|
|
18
|
+
class: @error.class.to_s,
|
|
19
|
+
message: clean_message,
|
|
20
|
+
location: error_location
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def error_location
|
|
27
|
+
path, line = syntax_location || backtrace_location
|
|
28
|
+
return @fallback_path || "Location unavailable" unless path
|
|
29
|
+
|
|
30
|
+
relative_location(path, line)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def syntax_location
|
|
34
|
+
return unless @error.is_a?(SyntaxError)
|
|
35
|
+
|
|
36
|
+
line = clean_message.lines.first&.[](/:(\d+):/, 1)
|
|
37
|
+
[@error.path || @fallback_path, line] if line
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def backtrace_location
|
|
41
|
+
locations = @error.backtrace_locations || []
|
|
42
|
+
location =
|
|
43
|
+
locations.find { |candidate| same_path?(candidate, @fallback_path) } ||
|
|
44
|
+
locations.find { |candidate| !sandals_path?(candidate) } ||
|
|
45
|
+
locations.first
|
|
46
|
+
|
|
47
|
+
[location&.absolute_path || location&.path, location&.lineno]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def same_path?(location, path)
|
|
51
|
+
path && File.expand_path(location.absolute_path || location.path) == File.expand_path(path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sandals_path?(location)
|
|
55
|
+
location.absolute_path.to_s.include?("/lib/sandals/")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relative_location(path, line)
|
|
59
|
+
source_path = path.delete_prefix("#{Dir.pwd}/")
|
|
60
|
+
"#{source_path}:#{line}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clean_message
|
|
64
|
+
@error.message.gsub(ANSI_ESCAPE, "").strip
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -10,57 +10,112 @@ module Sandals
|
|
|
10
10
|
DEFAULT_STYLES = <<~CSS
|
|
11
11
|
:root {
|
|
12
12
|
color-scheme: light;
|
|
13
|
+
--color-canvas: #f7f6f2;
|
|
14
|
+
--color-surface: #ffffff;
|
|
15
|
+
--color-surface-subtle: #efede7;
|
|
16
|
+
--color-text: #242320;
|
|
17
|
+
--color-muted: #6b6963;
|
|
18
|
+
--color-border: #d8d5cc;
|
|
19
|
+
--color-border-strong: #aaa69b;
|
|
20
|
+
--color-accent: #6558d3;
|
|
21
|
+
--color-accent-soft: #eeecff;
|
|
22
|
+
--color-danger: #a9362a;
|
|
23
|
+
--color-danger-soft: #fff0ed;
|
|
24
|
+
--color-success: #187451;
|
|
25
|
+
--color-success-soft: #eaf8f1;
|
|
26
|
+
--radius-control: 0.5rem;
|
|
27
|
+
--radius-panel: 0.75rem;
|
|
28
|
+
--shadow-focus: 0 0 0 3px rgb(101 88 211 / 20%);
|
|
29
|
+
--space-1: 0.375rem;
|
|
30
|
+
--space-2: 0.75rem;
|
|
31
|
+
--space-3: 1rem;
|
|
32
|
+
--space-4: 1.5rem;
|
|
33
|
+
--space-5: 2rem;
|
|
34
|
+
--space-6: 3rem;
|
|
13
35
|
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
14
|
-
background:
|
|
15
|
-
color:
|
|
36
|
+
background: var(--color-canvas);
|
|
37
|
+
color: var(--color-text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
*,
|
|
41
|
+
*::before,
|
|
42
|
+
*::after {
|
|
43
|
+
box-sizing: border-box;
|
|
16
44
|
}
|
|
17
45
|
|
|
18
46
|
body {
|
|
19
47
|
margin: 0;
|
|
48
|
+
min-width: 0;
|
|
49
|
+
background: var(--color-canvas);
|
|
50
|
+
font-size: 1rem;
|
|
51
|
+
line-height: 1.5;
|
|
20
52
|
}
|
|
21
53
|
|
|
22
54
|
main {
|
|
23
|
-
box-sizing: border-box;
|
|
24
55
|
width: min(100%, 48rem);
|
|
25
56
|
margin: 0 auto;
|
|
26
|
-
padding:
|
|
57
|
+
padding: clamp(var(--space-4), 6vw, var(--space-6));
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
h1 {
|
|
30
61
|
margin: 0;
|
|
31
|
-
|
|
32
|
-
|
|
62
|
+
max-width: 18ch;
|
|
63
|
+
font-size: clamp(2.25rem, 7vw, 3.75rem);
|
|
64
|
+
line-height: 0.98;
|
|
65
|
+
letter-spacing: -0.045em;
|
|
66
|
+
text-wrap: balance;
|
|
67
|
+
overflow-wrap: anywhere;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
h2 {
|
|
71
|
+
margin: 0;
|
|
72
|
+
font-size: clamp(1.35rem, 4vw, 1.75rem);
|
|
73
|
+
line-height: 1.15;
|
|
74
|
+
letter-spacing: -0.02em;
|
|
75
|
+
text-wrap: balance;
|
|
33
76
|
}
|
|
34
77
|
|
|
35
78
|
p {
|
|
36
79
|
margin: 0;
|
|
37
|
-
|
|
80
|
+
max-width: 68ch;
|
|
81
|
+
line-height: 1.65;
|
|
82
|
+
overflow-wrap: anywhere;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.error {
|
|
86
|
+
padding: var(--space-2) var(--space-3);
|
|
87
|
+
border: 1px solid #efb2aa;
|
|
88
|
+
border-radius: var(--radius-control);
|
|
89
|
+
background: var(--color-danger-soft);
|
|
90
|
+
color: var(--color-danger);
|
|
38
91
|
}
|
|
39
92
|
|
|
40
|
-
.error,
|
|
41
93
|
.field-error {
|
|
42
|
-
color:
|
|
94
|
+
color: var(--color-danger);
|
|
43
95
|
}
|
|
44
96
|
|
|
45
97
|
.notice {
|
|
46
|
-
|
|
98
|
+
padding: var(--space-2) var(--space-3);
|
|
99
|
+
border: 1px solid #a9dec6;
|
|
100
|
+
border-radius: var(--radius-control);
|
|
101
|
+
background: var(--color-success-soft);
|
|
102
|
+
color: var(--color-success);
|
|
47
103
|
}
|
|
48
104
|
|
|
49
105
|
.runtime-errors {
|
|
50
|
-
box-sizing: border-box;
|
|
51
106
|
width: min(100%, 48rem);
|
|
52
107
|
margin: 0 auto;
|
|
53
|
-
padding:
|
|
108
|
+
padding: var(--space-3) var(--space-4) 0;
|
|
54
109
|
}
|
|
55
110
|
|
|
56
111
|
.action-error {
|
|
57
112
|
display: grid;
|
|
58
|
-
gap:
|
|
59
|
-
padding:
|
|
60
|
-
border: 1px solid #
|
|
61
|
-
border-radius:
|
|
62
|
-
background:
|
|
63
|
-
color:
|
|
113
|
+
gap: var(--space-1);
|
|
114
|
+
padding: var(--space-3);
|
|
115
|
+
border: 1px solid #efb2aa;
|
|
116
|
+
border-radius: var(--radius-control);
|
|
117
|
+
background: var(--color-danger-soft);
|
|
118
|
+
color: var(--color-danger);
|
|
64
119
|
}
|
|
65
120
|
|
|
66
121
|
.action-error code {
|
|
@@ -69,17 +124,34 @@ module Sandals
|
|
|
69
124
|
|
|
70
125
|
.field-error {
|
|
71
126
|
font-size: 0.875rem;
|
|
127
|
+
font-weight: 450;
|
|
72
128
|
}
|
|
73
129
|
|
|
74
130
|
img {
|
|
75
131
|
display: block;
|
|
76
132
|
max-width: 100%;
|
|
77
133
|
height: auto;
|
|
134
|
+
border-radius: var(--radius-panel);
|
|
78
135
|
}
|
|
79
136
|
|
|
80
137
|
a {
|
|
81
|
-
color:
|
|
138
|
+
color: var(--color-accent);
|
|
139
|
+
font-weight: 550;
|
|
82
140
|
text-underline-offset: 0.15em;
|
|
141
|
+
text-decoration-thickness: 0.08em;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
a:hover {
|
|
145
|
+
text-decoration-thickness: 0.14em;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
a:focus-visible,
|
|
149
|
+
button:focus-visible,
|
|
150
|
+
input:focus-visible,
|
|
151
|
+
textarea:focus-visible,
|
|
152
|
+
select:focus-visible {
|
|
153
|
+
outline: 2px solid var(--color-accent);
|
|
154
|
+
outline-offset: 2px;
|
|
83
155
|
}
|
|
84
156
|
|
|
85
157
|
.text-field,
|
|
@@ -88,7 +160,9 @@ module Sandals
|
|
|
88
160
|
.file-field,
|
|
89
161
|
.select {
|
|
90
162
|
display: grid;
|
|
91
|
-
|
|
163
|
+
min-width: 0;
|
|
164
|
+
gap: var(--space-1);
|
|
165
|
+
font-weight: 600;
|
|
92
166
|
}
|
|
93
167
|
|
|
94
168
|
.text-field input,
|
|
@@ -96,20 +170,40 @@ module Sandals
|
|
|
96
170
|
.text-area textarea,
|
|
97
171
|
.file-field input,
|
|
98
172
|
.select select {
|
|
99
|
-
box-sizing: border-box;
|
|
100
173
|
width: 100%;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
border
|
|
104
|
-
|
|
174
|
+
min-height: 2.75rem;
|
|
175
|
+
padding: 0.625rem var(--space-2);
|
|
176
|
+
border: 1px solid var(--color-border-strong);
|
|
177
|
+
border-radius: var(--radius-control);
|
|
178
|
+
background: var(--color-surface);
|
|
105
179
|
color: inherit;
|
|
106
180
|
font: inherit;
|
|
181
|
+
font-weight: 400;
|
|
182
|
+
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.text-field input:hover,
|
|
186
|
+
.number-field input:hover,
|
|
187
|
+
.text-area textarea:hover,
|
|
188
|
+
.file-field input:hover,
|
|
189
|
+
.select select:hover {
|
|
190
|
+
border-color: var(--color-text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.text-field input:focus,
|
|
194
|
+
.number-field input:focus,
|
|
195
|
+
.text-area textarea:focus,
|
|
196
|
+
.file-field input:focus,
|
|
197
|
+
.select select:focus {
|
|
198
|
+
border-color: var(--color-accent);
|
|
199
|
+
box-shadow: var(--shadow-focus);
|
|
107
200
|
}
|
|
108
201
|
|
|
109
202
|
input[aria-invalid="true"],
|
|
110
203
|
textarea[aria-invalid="true"],
|
|
111
204
|
select[aria-invalid="true"] {
|
|
112
|
-
border-color:
|
|
205
|
+
border-color: var(--color-danger);
|
|
206
|
+
background: var(--color-danger-soft);
|
|
113
207
|
}
|
|
114
208
|
|
|
115
209
|
.text-area textarea {
|
|
@@ -120,88 +214,139 @@ module Sandals
|
|
|
120
214
|
.checkbox {
|
|
121
215
|
display: flex;
|
|
122
216
|
align-items: center;
|
|
123
|
-
|
|
217
|
+
min-width: 0;
|
|
218
|
+
gap: var(--space-2);
|
|
219
|
+
font-weight: 550;
|
|
124
220
|
}
|
|
125
221
|
|
|
126
222
|
.checkbox input {
|
|
127
|
-
width:
|
|
128
|
-
height:
|
|
223
|
+
width: 1.125rem;
|
|
224
|
+
height: 1.125rem;
|
|
129
225
|
margin: 0;
|
|
226
|
+
accent-color: var(--color-accent);
|
|
130
227
|
}
|
|
131
228
|
|
|
132
229
|
.radio {
|
|
133
230
|
display: grid;
|
|
134
|
-
|
|
231
|
+
min-width: 0;
|
|
232
|
+
gap: var(--space-2);
|
|
135
233
|
margin: 0;
|
|
136
234
|
padding: 0;
|
|
137
235
|
border: 0;
|
|
138
236
|
}
|
|
139
237
|
|
|
140
238
|
.radio legend {
|
|
141
|
-
margin-bottom:
|
|
239
|
+
margin-bottom: var(--space-1);
|
|
240
|
+
font-weight: 600;
|
|
142
241
|
}
|
|
143
242
|
|
|
144
243
|
.radio label {
|
|
145
244
|
display: flex;
|
|
146
245
|
align-items: center;
|
|
147
|
-
gap:
|
|
246
|
+
gap: var(--space-2);
|
|
148
247
|
}
|
|
149
248
|
|
|
150
249
|
.radio input {
|
|
151
250
|
margin: 0;
|
|
251
|
+
accent-color: var(--color-accent);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.table-scroll {
|
|
255
|
+
max-width: 100%;
|
|
256
|
+
overflow-x: auto;
|
|
257
|
+
border: 1px solid var(--color-border);
|
|
258
|
+
border-radius: var(--radius-panel);
|
|
259
|
+
background: var(--color-surface);
|
|
260
|
+
overscroll-behavior-inline: contain;
|
|
152
261
|
}
|
|
153
262
|
|
|
154
263
|
table {
|
|
155
264
|
width: 100%;
|
|
265
|
+
min-width: 36rem;
|
|
156
266
|
border-collapse: collapse;
|
|
157
|
-
background:
|
|
267
|
+
background: var(--color-surface);
|
|
158
268
|
}
|
|
159
269
|
|
|
160
270
|
th,
|
|
161
271
|
td {
|
|
162
|
-
padding:
|
|
163
|
-
border: 1px solid
|
|
272
|
+
padding: var(--space-2) var(--space-3);
|
|
273
|
+
border-bottom: 1px solid var(--color-border);
|
|
164
274
|
text-align: left;
|
|
165
275
|
vertical-align: top;
|
|
276
|
+
overflow-wrap: anywhere;
|
|
166
277
|
}
|
|
167
278
|
|
|
168
279
|
th {
|
|
169
|
-
background:
|
|
280
|
+
background: var(--color-surface-subtle);
|
|
170
281
|
font-weight: 600;
|
|
171
282
|
}
|
|
172
283
|
|
|
284
|
+
tbody tr:last-child td {
|
|
285
|
+
border-bottom: 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
173
288
|
button {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
border
|
|
177
|
-
|
|
289
|
+
min-height: 2.75rem;
|
|
290
|
+
padding: 0.625rem var(--space-3);
|
|
291
|
+
border: 1px solid var(--color-text);
|
|
292
|
+
border-radius: var(--radius-control);
|
|
293
|
+
background: var(--color-text);
|
|
178
294
|
color: white;
|
|
179
295
|
font: inherit;
|
|
296
|
+
font-weight: 650;
|
|
180
297
|
cursor: pointer;
|
|
298
|
+
transition: transform 100ms ease, opacity 100ms ease, background 100ms ease;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
button:hover:not(:disabled) {
|
|
302
|
+
background: #3a3935;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
button:active:not(:disabled) {
|
|
306
|
+
transform: translateY(1px);
|
|
181
307
|
}
|
|
182
308
|
|
|
183
309
|
button[aria-busy="true"] {
|
|
184
310
|
cursor: wait;
|
|
185
|
-
opacity: 0.
|
|
311
|
+
opacity: 0.65;
|
|
186
312
|
}
|
|
187
313
|
|
|
188
314
|
.stack {
|
|
189
315
|
display: flex;
|
|
190
316
|
flex-direction: column;
|
|
191
|
-
|
|
317
|
+
min-width: 0;
|
|
318
|
+
gap: var(--space-3);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.stack > * {
|
|
322
|
+
min-width: 0;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.stack > h2 {
|
|
326
|
+
margin-top: var(--space-4);
|
|
192
327
|
}
|
|
193
328
|
|
|
194
329
|
.flow {
|
|
195
330
|
display: flex;
|
|
196
331
|
flex-flow: row wrap;
|
|
197
332
|
align-items: center;
|
|
198
|
-
|
|
333
|
+
min-width: 0;
|
|
334
|
+
gap: var(--space-2);
|
|
199
335
|
}
|
|
200
336
|
|
|
201
337
|
.form {
|
|
202
|
-
display:
|
|
203
|
-
|
|
204
|
-
|
|
338
|
+
display: contents;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
@media (max-width: 32rem) {
|
|
342
|
+
main {
|
|
343
|
+
padding-inline: var(--space-3);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.runtime-errors {
|
|
347
|
+
padding-inline: var(--space-3);
|
|
348
|
+
}
|
|
349
|
+
|
|
205
350
|
}
|
|
206
351
|
CSS
|
|
207
352
|
|
|
@@ -211,7 +356,8 @@ module Sandals
|
|
|
211
356
|
render_children(view),
|
|
212
357
|
revision:,
|
|
213
358
|
refresh_interval: application.refresh_interval,
|
|
214
|
-
development_reload
|
|
359
|
+
development_reload:,
|
|
360
|
+
app_styles: application.styles.fetch(:app, {})
|
|
215
361
|
)
|
|
216
362
|
end
|
|
217
363
|
|
|
@@ -247,11 +393,11 @@ module Sandals
|
|
|
247
393
|
when String
|
|
248
394
|
escape(element)
|
|
249
395
|
when Title
|
|
250
|
-
"<h1>#{escape(element.content)}</h1>"
|
|
396
|
+
"<h1#{style_attribute(:title, element)}>#{escape(element.content)}</h1>"
|
|
251
397
|
when Subtitle
|
|
252
|
-
"<h2>#{escape(element.content)}</h2>"
|
|
398
|
+
"<h2#{style_attribute(:subtitle, element)}>#{escape(element.content)}</h2>"
|
|
253
399
|
when Para
|
|
254
|
-
"<p>#{element.parts.map { |part| render_element(part) }.join}</p>"
|
|
400
|
+
"<p#{style_attribute(:para, element)}>#{element.parts.map { |part| render_element(part) }.join}</p>"
|
|
255
401
|
when Error
|
|
256
402
|
%(<p class="error" role="alert">#{escape(element.content)}</p>)
|
|
257
403
|
when Notice
|
|
@@ -288,7 +434,7 @@ module Sandals
|
|
|
288
434
|
when Submit
|
|
289
435
|
%(<button type="submit" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
|
|
290
436
|
when Button
|
|
291
|
-
%(<button type="button" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
|
|
437
|
+
%(<button type="button" data-sandals-target="#{escape(element.id)}"#{style_attribute(:button, element)}>#{escape(element.content)}</button>)
|
|
292
438
|
when Strong
|
|
293
439
|
"<strong>#{escape(element.content)}</strong>"
|
|
294
440
|
when Emphasis
|
|
@@ -306,7 +452,7 @@ module Sandals
|
|
|
306
452
|
|
|
307
453
|
def container(class_name, element)
|
|
308
454
|
content = element.children.map { |child| render_element(child) }.join
|
|
309
|
-
%(<div class="#{class_name}">#{content}</div>)
|
|
455
|
+
%(<div class="#{class_name}"#{style_attribute(class_name.to_sym, element)}>#{content}</div>)
|
|
310
456
|
end
|
|
311
457
|
|
|
312
458
|
def form(element)
|
|
@@ -322,7 +468,7 @@ module Sandals
|
|
|
322
468
|
cells = row.map { |cell| "<td>#{render_element(cell)}</td>" }.join
|
|
323
469
|
"<tr>#{cells}</tr>"
|
|
324
470
|
end.join
|
|
325
|
-
"
|
|
471
|
+
%(<div class="table-scroll"><table><thead><tr>#{headers}</tr></thead><tbody>#{rows}</tbody></table></div>)
|
|
326
472
|
end
|
|
327
473
|
|
|
328
474
|
def validation_attributes(element)
|
|
@@ -342,7 +488,8 @@ module Sandals
|
|
|
342
488
|
content,
|
|
343
489
|
revision:,
|
|
344
490
|
refresh_interval:,
|
|
345
|
-
development_reload
|
|
491
|
+
development_reload:,
|
|
492
|
+
app_styles:
|
|
346
493
|
)
|
|
347
494
|
refresh_attribute =
|
|
348
495
|
if refresh_interval
|
|
@@ -363,14 +510,22 @@ module Sandals
|
|
|
363
510
|
<script src="#{TURBO_URL}"></script>
|
|
364
511
|
<script>#{client_script}</script>
|
|
365
512
|
</head>
|
|
366
|
-
<body>
|
|
513
|
+
<body#{style_attribute(:app, app_styles.slice(:background, :color))}>
|
|
514
|
+
<div id="sandals-connection-status" class="runtime-errors" aria-live="assertive"></div>
|
|
367
515
|
<div id="sandals-errors" class="runtime-errors" aria-live="assertive"></div>
|
|
368
|
-
<main id="sandals-view" data-sandals-revision="#{revision}"#{refresh_attribute}#{reload_attribute}>#{content}</main>
|
|
516
|
+
<main id="sandals-view" data-sandals-revision="#{revision}"#{refresh_attribute}#{reload_attribute}#{style_attribute(:app, app_styles)}>#{content}</main>
|
|
369
517
|
</body>
|
|
370
518
|
</html>
|
|
371
519
|
HTML
|
|
372
520
|
end
|
|
373
521
|
|
|
522
|
+
def style_attribute(element, styled)
|
|
523
|
+
properties = styled.respond_to?(:styles) ? styled.styles : styled
|
|
524
|
+
return "" if properties.empty?
|
|
525
|
+
|
|
526
|
+
%( style="#{escape(Style.inline(element, properties))}")
|
|
527
|
+
end
|
|
528
|
+
|
|
374
529
|
def client_script
|
|
375
530
|
<<~JAVASCRIPT
|
|
376
531
|
document.addEventListener("DOMContentLoaded", () => {
|
|
@@ -386,6 +541,8 @@ module Sandals
|
|
|
386
541
|
let codeVersion
|
|
387
542
|
let checkingCode = false
|
|
388
543
|
let refreshTimer
|
|
544
|
+
let reconnectTimer
|
|
545
|
+
let disconnected = false
|
|
389
546
|
const pending = []
|
|
390
547
|
const pendingButtons = new Map()
|
|
391
548
|
|
|
@@ -412,6 +569,65 @@ module Sandals
|
|
|
412
569
|
}
|
|
413
570
|
}
|
|
414
571
|
|
|
572
|
+
function releaseAllPendingButtons() {
|
|
573
|
+
for (const button of pendingButtons.values()) {
|
|
574
|
+
button.disabled = false
|
|
575
|
+
button.removeAttribute("aria-busy")
|
|
576
|
+
delete button.dataset.sandalsPending
|
|
577
|
+
}
|
|
578
|
+
pendingButtons.clear()
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function showConnectionLost() {
|
|
582
|
+
const region = document.querySelector("#sandals-connection-status")
|
|
583
|
+
const section = document.createElement("section")
|
|
584
|
+
const title = document.createElement("strong")
|
|
585
|
+
const message = document.createElement("p")
|
|
586
|
+
|
|
587
|
+
section.className = "action-error"
|
|
588
|
+
section.setAttribute("role", "alert")
|
|
589
|
+
title.textContent = "Connection lost"
|
|
590
|
+
message.textContent = "Waiting for Sandals to reconnect…"
|
|
591
|
+
section.append(title, message)
|
|
592
|
+
region.replaceChildren(section)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function connectionLost() {
|
|
596
|
+
if (disconnected) return
|
|
597
|
+
|
|
598
|
+
disconnected = true
|
|
599
|
+
pending.length = 0
|
|
600
|
+
clearTimeout(refreshTimer)
|
|
601
|
+
releaseAllPendingButtons()
|
|
602
|
+
showConnectionLost()
|
|
603
|
+
scheduleReconnect()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function handleRequestError(error) {
|
|
607
|
+
if (error instanceof TypeError) {
|
|
608
|
+
connectionLost()
|
|
609
|
+
} else {
|
|
610
|
+
console.error(error)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function scheduleReconnect() {
|
|
615
|
+
clearTimeout(reconnectTimer)
|
|
616
|
+
reconnectTimer = setTimeout(checkConnection, 1000)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function checkConnection() {
|
|
620
|
+
if (!disconnected) return
|
|
621
|
+
if (document.hidden) return
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
await fetch("/", { cache: "no-store" })
|
|
625
|
+
window.location.reload()
|
|
626
|
+
} catch (error) {
|
|
627
|
+
scheduleReconnect()
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
415
631
|
function showCodeError(error) {
|
|
416
632
|
const region = document.querySelector("#sandals-errors")
|
|
417
633
|
const section = document.createElement("section")
|
|
@@ -429,7 +645,7 @@ module Sandals
|
|
|
429
645
|
}
|
|
430
646
|
|
|
431
647
|
async function checkForCodeChanges() {
|
|
432
|
-
if (!developmentReload || document.hidden || checkingCode) return
|
|
648
|
+
if (!developmentReload || document.hidden || checkingCode || disconnected) return
|
|
433
649
|
|
|
434
650
|
checkingCode = true
|
|
435
651
|
try {
|
|
@@ -447,12 +663,16 @@ module Sandals
|
|
|
447
663
|
}
|
|
448
664
|
|
|
449
665
|
if (result.error) showCodeError(result.error)
|
|
666
|
+
} catch (error) {
|
|
667
|
+
handleRequestError(error)
|
|
450
668
|
} finally {
|
|
451
669
|
checkingCode = false
|
|
452
670
|
}
|
|
453
671
|
}
|
|
454
672
|
|
|
455
673
|
function enqueue(message) {
|
|
674
|
+
if (disconnected) return
|
|
675
|
+
|
|
456
676
|
scheduleRefresh()
|
|
457
677
|
const last = pending.at(-1)
|
|
458
678
|
|
|
@@ -472,14 +692,14 @@ module Sandals
|
|
|
472
692
|
|
|
473
693
|
function scheduleRefresh() {
|
|
474
694
|
clearTimeout(refreshTimer)
|
|
475
|
-
if (!refreshInterval || document.hidden) return
|
|
695
|
+
if (!refreshInterval || document.hidden || disconnected) return
|
|
476
696
|
|
|
477
697
|
refreshTimer = setTimeout(refreshView, refreshInterval * 1000)
|
|
478
698
|
}
|
|
479
699
|
|
|
480
700
|
async function refreshView() {
|
|
481
701
|
clearTimeout(refreshTimer)
|
|
482
|
-
if (!refreshInterval || document.hidden) return
|
|
702
|
+
if (!refreshInterval || document.hidden || disconnected) return
|
|
483
703
|
if (active || pending.length > 0) {
|
|
484
704
|
scheduleRefresh()
|
|
485
705
|
return
|
|
@@ -513,6 +733,8 @@ module Sandals
|
|
|
513
733
|
} else if (!response.ok) {
|
|
514
734
|
throw new Error(body)
|
|
515
735
|
}
|
|
736
|
+
} catch (error) {
|
|
737
|
+
handleRequestError(error)
|
|
516
738
|
} finally {
|
|
517
739
|
active = false
|
|
518
740
|
drain()
|
|
@@ -521,7 +743,7 @@ module Sandals
|
|
|
521
743
|
}
|
|
522
744
|
|
|
523
745
|
async function drain() {
|
|
524
|
-
if (active || pending.length === 0) return
|
|
746
|
+
if (active || disconnected || pending.length === 0) return
|
|
525
747
|
|
|
526
748
|
active = true
|
|
527
749
|
const batchRevision = pending[0].revision
|
|
@@ -578,6 +800,8 @@ module Sandals
|
|
|
578
800
|
} else if (!response.ok) {
|
|
579
801
|
throw new Error(body)
|
|
580
802
|
}
|
|
803
|
+
} catch (error) {
|
|
804
|
+
handleRequestError(error)
|
|
581
805
|
} finally {
|
|
582
806
|
releasePendingButtons(events)
|
|
583
807
|
active = false
|
|
@@ -616,6 +840,7 @@ module Sandals
|
|
|
616
840
|
const button = event.target.closest("button[data-sandals-target]")
|
|
617
841
|
if (!button) return
|
|
618
842
|
if (button.type === "submit") return
|
|
843
|
+
if (disconnected) return
|
|
619
844
|
if (button.disabled || button.dataset.sandalsPending) return
|
|
620
845
|
|
|
621
846
|
markButtonPending(button)
|
|
@@ -652,6 +877,7 @@ module Sandals
|
|
|
652
877
|
event.submitter ||
|
|
653
878
|
form.querySelector('button[type="submit"][data-sandals-target]')
|
|
654
879
|
if (!button) return
|
|
880
|
+
if (disconnected) return
|
|
655
881
|
if (button.disabled || button.dataset.sandalsPending) return
|
|
656
882
|
|
|
657
883
|
markButtonPending(button)
|
|
@@ -664,6 +890,10 @@ module Sandals
|
|
|
664
890
|
document.addEventListener("visibilitychange", () => {
|
|
665
891
|
clearTimeout(refreshTimer)
|
|
666
892
|
if (!document.hidden) {
|
|
893
|
+
if (disconnected) {
|
|
894
|
+
checkConnection()
|
|
895
|
+
return
|
|
896
|
+
}
|
|
667
897
|
refreshView()
|
|
668
898
|
checkForCodeChanges()
|
|
669
899
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class Style
|
|
5
|
+
ELEMENTS = %i[app stack flow title subtitle para button].freeze
|
|
6
|
+
PROPERTIES = %i[
|
|
7
|
+
background color border radius size weight width padding margin gap align
|
|
8
|
+
].freeze
|
|
9
|
+
DIMENSIONS = %i[radius size width padding margin gap].freeze
|
|
10
|
+
|
|
11
|
+
CSS_PROPERTIES = {
|
|
12
|
+
background: "background",
|
|
13
|
+
color: "color",
|
|
14
|
+
border: "border",
|
|
15
|
+
radius: "border-radius",
|
|
16
|
+
size: "font-size",
|
|
17
|
+
weight: "font-weight",
|
|
18
|
+
width: "width",
|
|
19
|
+
padding: "padding",
|
|
20
|
+
margin: "margin",
|
|
21
|
+
gap: "gap"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def self.validate_element!(element)
|
|
25
|
+
name = element.to_sym
|
|
26
|
+
return name if ELEMENTS.include?(name)
|
|
27
|
+
|
|
28
|
+
raise ArgumentError,
|
|
29
|
+
"unknown style element: #{element.inspect}; expected one of #{ELEMENTS.join(", ")}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.normalize(element, properties)
|
|
33
|
+
element = validate_element!(element)
|
|
34
|
+
properties.to_h do |name, value|
|
|
35
|
+
name = name.to_sym
|
|
36
|
+
unless PROPERTIES.include?(name)
|
|
37
|
+
raise ArgumentError, "unknown style property for #{element}: #{name}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
[name, normalize_value(name, value)]
|
|
41
|
+
end.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.inline(element, properties)
|
|
45
|
+
properties.map do |name, value|
|
|
46
|
+
property = name == :align ? alignment_property(element) : CSS_PROPERTIES.fetch(name)
|
|
47
|
+
"#{property}: #{value}"
|
|
48
|
+
end.join("; ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.normalize_value(name, value)
|
|
52
|
+
if value.is_a?(Numeric)
|
|
53
|
+
return value.to_s unless DIMENSIONS.include?(name)
|
|
54
|
+
|
|
55
|
+
return value.zero? ? "0" : "#{value}px"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
value = value.to_s
|
|
59
|
+
if value.empty? || value.match?(/[;{}<>]/)
|
|
60
|
+
raise ArgumentError, "invalid value for style property #{name}: #{value.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
value
|
|
64
|
+
end
|
|
65
|
+
private_class_method :normalize_value
|
|
66
|
+
|
|
67
|
+
def self.alignment_property(element)
|
|
68
|
+
%i[stack flow].include?(element) ? "align-items" : "text-align"
|
|
69
|
+
end
|
|
70
|
+
private_class_method :alignment_property
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
module Styled
|
|
74
|
+
attr_reader :styles
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def initialize_styles(styles)
|
|
79
|
+
@styles = styles.freeze
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/sandals/version.rb
CHANGED
data/lib/sandals/view.rb
CHANGED
|
@@ -8,22 +8,23 @@ module Sandals
|
|
|
8
8
|
|
|
9
9
|
attr_reader :children
|
|
10
10
|
|
|
11
|
-
def initialize
|
|
11
|
+
def initialize(styles: {})
|
|
12
12
|
@children = []
|
|
13
13
|
@containers = [self]
|
|
14
14
|
@next_control_id = 0
|
|
15
|
+
@styles = styles
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
def title(content)
|
|
18
|
-
add Title.new(content)
|
|
18
|
+
def title(content, **styles)
|
|
19
|
+
add Title.new(content, styles: styles_for(:title, styles))
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
def subtitle(content)
|
|
22
|
-
add Subtitle.new(content)
|
|
22
|
+
def subtitle(content, **styles)
|
|
23
|
+
add Subtitle.new(content, styles: styles_for(:subtitle, styles))
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
def para(*parts)
|
|
26
|
-
add Para.new(parts)
|
|
26
|
+
def para(*parts, **styles)
|
|
27
|
+
add Para.new(parts, styles: styles_for(:para, styles))
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def error(content)
|
|
@@ -136,8 +137,13 @@ module Sandals
|
|
|
136
137
|
add FileField.new(control_id("file-field"), label, error:, action:)
|
|
137
138
|
end
|
|
138
139
|
|
|
139
|
-
def button(content, &action)
|
|
140
|
-
add Button.new(
|
|
140
|
+
def button(content, **styles, &action)
|
|
141
|
+
add Button.new(
|
|
142
|
+
control_id("button"),
|
|
143
|
+
content,
|
|
144
|
+
action:,
|
|
145
|
+
styles: styles_for(:button, styles)
|
|
146
|
+
)
|
|
141
147
|
end
|
|
142
148
|
|
|
143
149
|
def submit(content, &action)
|
|
@@ -156,12 +162,12 @@ module Sandals
|
|
|
156
162
|
Emphasis.new(content)
|
|
157
163
|
end
|
|
158
164
|
|
|
159
|
-
def stack(&block)
|
|
160
|
-
container Stack.new, &block
|
|
165
|
+
def stack(**styles, &block)
|
|
166
|
+
container Stack.new(styles: styles_for(:stack, styles)), &block
|
|
161
167
|
end
|
|
162
168
|
|
|
163
|
-
def flow(&block)
|
|
164
|
-
container Flow.new, &block
|
|
169
|
+
def flow(**styles, &block)
|
|
170
|
+
container Flow.new(styles: styles_for(:flow, styles)), &block
|
|
165
171
|
end
|
|
166
172
|
|
|
167
173
|
def form(&block)
|
|
@@ -199,13 +205,20 @@ module Sandals
|
|
|
199
205
|
model:, bind:, value:, error:, action:, default:
|
|
200
206
|
).deconstruct
|
|
201
207
|
end
|
|
208
|
+
|
|
209
|
+
def styles_for(element, local_styles)
|
|
210
|
+
@styles.fetch(element, {}).merge(Style.normalize(element, local_styles))
|
|
211
|
+
end
|
|
202
212
|
end
|
|
203
213
|
|
|
204
214
|
class Container
|
|
215
|
+
include Styled
|
|
216
|
+
|
|
205
217
|
attr_reader :children
|
|
206
218
|
|
|
207
|
-
def initialize
|
|
219
|
+
def initialize(styles: {})
|
|
208
220
|
@children = []
|
|
221
|
+
initialize_styles(styles)
|
|
209
222
|
end
|
|
210
223
|
end
|
|
211
224
|
|
|
@@ -214,26 +227,35 @@ module Sandals
|
|
|
214
227
|
class Form < Container; end
|
|
215
228
|
|
|
216
229
|
class Title
|
|
230
|
+
include Styled
|
|
231
|
+
|
|
217
232
|
attr_reader :content
|
|
218
233
|
|
|
219
|
-
def initialize(content)
|
|
234
|
+
def initialize(content, styles: {})
|
|
220
235
|
@content = content.to_s
|
|
236
|
+
initialize_styles(styles)
|
|
221
237
|
end
|
|
222
238
|
end
|
|
223
239
|
|
|
224
240
|
class Subtitle
|
|
241
|
+
include Styled
|
|
242
|
+
|
|
225
243
|
attr_reader :content
|
|
226
244
|
|
|
227
|
-
def initialize(content)
|
|
245
|
+
def initialize(content, styles: {})
|
|
228
246
|
@content = content.to_s
|
|
247
|
+
initialize_styles(styles)
|
|
229
248
|
end
|
|
230
249
|
end
|
|
231
250
|
|
|
232
251
|
class Para
|
|
252
|
+
include Styled
|
|
253
|
+
|
|
233
254
|
attr_reader :parts
|
|
234
255
|
|
|
235
|
-
def initialize(parts)
|
|
256
|
+
def initialize(parts, styles: {})
|
|
236
257
|
@parts = parts
|
|
258
|
+
initialize_styles(styles)
|
|
237
259
|
end
|
|
238
260
|
end
|
|
239
261
|
|
|
@@ -475,13 +497,15 @@ module Sandals
|
|
|
475
497
|
|
|
476
498
|
class Button
|
|
477
499
|
include Actionable
|
|
500
|
+
include Styled
|
|
478
501
|
|
|
479
502
|
attr_reader :id, :content
|
|
480
503
|
|
|
481
|
-
def initialize(id, content, action:)
|
|
504
|
+
def initialize(id, content, action:, styles: {})
|
|
482
505
|
@id = id
|
|
483
506
|
@content = content.to_s
|
|
484
507
|
@action = action
|
|
508
|
+
initialize_styles(styles)
|
|
485
509
|
end
|
|
486
510
|
|
|
487
511
|
def activate
|
data/lib/sandals.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "sandals/version"
|
|
4
|
+
require_relative "sandals/error_details"
|
|
4
5
|
require_relative "sandals/binding_resolver"
|
|
5
6
|
require_relative "sandals/action_error"
|
|
6
7
|
require_relative "sandals/uploaded_file"
|
|
8
|
+
require_relative "sandals/style"
|
|
7
9
|
require_relative "sandals/view"
|
|
8
10
|
require_relative "sandals/html_renderer"
|
|
9
11
|
require_relative "sandals/snapshot_history"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sandals
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Benito Serna
|
|
@@ -56,14 +56,17 @@ files:
|
|
|
56
56
|
- lib/sandals/binding_resolver.rb
|
|
57
57
|
- lib/sandals/cli.rb
|
|
58
58
|
- lib/sandals/code_reloader.rb
|
|
59
|
+
- lib/sandals/error_details.rb
|
|
59
60
|
- lib/sandals/event_dispatcher.rb
|
|
60
61
|
- lib/sandals/html_renderer.rb
|
|
61
62
|
- lib/sandals/server.rb
|
|
62
63
|
- lib/sandals/snapshot_history.rb
|
|
64
|
+
- lib/sandals/style.rb
|
|
63
65
|
- lib/sandals/test_session.rb
|
|
64
66
|
- lib/sandals/uploaded_file.rb
|
|
65
67
|
- lib/sandals/version.rb
|
|
66
68
|
- lib/sandals/view.rb
|
|
69
|
+
homepage: https://rubygems.org/gems/sandals
|
|
67
70
|
licenses:
|
|
68
71
|
- MIT
|
|
69
72
|
metadata:
|