houseaccount 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -1
  3. data/MIT-LICENSE +22 -0
  4. data/README.md +230 -20
  5. data/art/autopilot.svg +9 -0
  6. data/art/design.svg +8 -0
  7. data/art/down.html.erb +95 -0
  8. data/art/fountain.svg +9 -0
  9. data/art/goldrush.svg +7 -0
  10. data/art/houseaccount.svg +9 -0
  11. data/art/houston.svg +8 -0
  12. data/art/icons.txt +184 -0
  13. data/config/locales/houseaccount.en.yml +12 -0
  14. data/lib/houseaccount/down.rb +19 -0
  15. data/lib/houseaccount/engine.rb +53 -0
  16. data/lib/houseaccount/form_builder/pins.rb +30 -0
  17. data/lib/houseaccount/form_builder.rb +84 -0
  18. data/lib/houseaccount/helpers/chats/asides.rb +49 -0
  19. data/lib/houseaccount/helpers/chats/asks.rb +40 -0
  20. data/lib/houseaccount/helpers/chats.rb +42 -0
  21. data/lib/houseaccount/helpers/dialogs.rb +47 -0
  22. data/lib/houseaccount/helpers/flows.rb +45 -0
  23. data/lib/houseaccount/helpers/heads.rb +42 -0
  24. data/lib/houseaccount/helpers/logos.rb +43 -0
  25. data/lib/houseaccount/helpers/notices.rb +55 -0
  26. data/lib/houseaccount/helpers.rb +13 -0
  27. data/lib/houseaccount/marks.rb +14 -0
  28. data/lib/houseaccount/message.rb +11 -0
  29. data/lib/houseaccount/version.rb +2 -2
  30. data/lib/houseaccount.rb +35 -2
  31. data/public/_headers +28 -0
  32. data/public/css/houseaccount.css +1 -0
  33. data/public/down/autopilot.html +87 -0
  34. data/public/down/design.html +87 -0
  35. data/public/down/fountain.html +87 -0
  36. data/public/down/goldrush.html +87 -0
  37. data/public/down/houseaccount.html +84 -0
  38. data/public/down/houston.html +87 -0
  39. data/public/favicon/autopilot-180.png +0 -0
  40. data/public/favicon/autopilot-192.png +0 -0
  41. data/public/favicon/autopilot-512.png +0 -0
  42. data/public/favicon/autopilot-96.png +0 -0
  43. data/public/favicon/autopilot-dev.svg +9 -0
  44. data/public/favicon/autopilot.ico +0 -0
  45. data/public/favicon/autopilot.svg +9 -0
  46. data/public/favicon/autopilot.webmanifest +12 -0
  47. data/public/favicon/design-180.png +0 -0
  48. data/public/favicon/design-192.png +0 -0
  49. data/public/favicon/design-512.png +0 -0
  50. data/public/favicon/design-96.png +0 -0
  51. data/public/favicon/design-dev.svg +8 -0
  52. data/public/favicon/design.ico +0 -0
  53. data/public/favicon/design.svg +8 -0
  54. data/public/favicon/design.webmanifest +12 -0
  55. data/public/favicon/fountain-180.png +0 -0
  56. data/public/favicon/fountain-192.png +0 -0
  57. data/public/favicon/fountain-512.png +0 -0
  58. data/public/favicon/fountain-96.png +0 -0
  59. data/public/favicon/fountain-dev.svg +9 -0
  60. data/public/favicon/fountain.ico +0 -0
  61. data/public/favicon/fountain.svg +9 -0
  62. data/public/favicon/fountain.webmanifest +12 -0
  63. data/public/favicon/goldrush-180.png +0 -0
  64. data/public/favicon/goldrush-192.png +0 -0
  65. data/public/favicon/goldrush-512.png +0 -0
  66. data/public/favicon/goldrush-96.png +0 -0
  67. data/public/favicon/goldrush-dev.svg +7 -0
  68. data/public/favicon/goldrush.ico +0 -0
  69. data/public/favicon/goldrush.svg +7 -0
  70. data/public/favicon/goldrush.webmanifest +12 -0
  71. data/public/favicon/houseaccount-180.png +0 -0
  72. data/public/favicon/houseaccount-192.png +0 -0
  73. data/public/favicon/houseaccount-512.png +0 -0
  74. data/public/favicon/houseaccount-96.png +0 -0
  75. data/public/favicon/houseaccount-dev.svg +9 -0
  76. data/public/favicon/houseaccount.ico +0 -0
  77. data/public/favicon/houseaccount.svg +9 -0
  78. data/public/favicon/houseaccount.webmanifest +12 -0
  79. data/public/favicon/houston-180.png +0 -0
  80. data/public/favicon/houston-192.png +0 -0
  81. data/public/favicon/houston-512.png +0 -0
  82. data/public/favicon/houston-96.png +0 -0
  83. data/public/favicon/houston-dev.svg +8 -0
  84. data/public/favicon/houston.ico +0 -0
  85. data/public/favicon/houston.svg +8 -0
  86. data/public/favicon/houston.webmanifest +12 -0
  87. data/public/favicon.ico +0 -0
  88. data/public/font/bootstrap-icons-6LILWPE2.woff2 +0 -0
  89. data/public/font/quicksand-500-latin-QHYSRFVK.woff2 +0 -0
  90. data/public/font/quicksand-500-latin-ext-QZ4PXVHB.woff2 +0 -0
  91. data/public/font/quicksand-500-latin-ext.woff2 +0 -0
  92. data/public/font/quicksand-500-latin.woff2 +0 -0
  93. data/public/font/quicksand-500-vietnamese-RRA5CUQB.woff2 +0 -0
  94. data/public/font/quicksand-500-vietnamese.woff2 +0 -0
  95. data/public/index.html +298 -0
  96. data/public/js/houseaccount.js +144 -0
  97. data/public/logo/autopilot.png +0 -0
  98. data/public/logo/autopilot.svg +9 -0
  99. data/public/logo/design.png +0 -0
  100. data/public/logo/design.svg +8 -0
  101. data/public/logo/fountain.png +0 -0
  102. data/public/logo/fountain.svg +9 -0
  103. data/public/logo/goldrush.png +0 -0
  104. data/public/logo/goldrush.svg +7 -0
  105. data/public/logo/houseaccount.png +0 -0
  106. data/public/logo/houseaccount.svg +9 -0
  107. data/public/logo/houston.png +0 -0
  108. data/public/logo/houston.svg +8 -0
  109. data/public/page/400.html +118 -0
  110. data/public/page/404.html +118 -0
  111. data/public/page/406-unsupported-browser.html +118 -0
  112. data/public/page/422.html +118 -0
  113. data/public/page/500.html +118 -0
  114. data/public/site.webmanifest +12 -0
  115. data/public/theme/bootstrap.css +11 -0
  116. data/public/theme/dawn.css +189 -0
  117. data/public/theme/dracula.css +189 -0
  118. data/public/theme/gruvbox.css +189 -0
  119. data/public/theme/monokai.css +190 -0
  120. data/public/theme/nord.css +189 -0
  121. data/public/theme/one_dark.css +189 -0
  122. data/public/theme/solarized.css +188 -0
  123. data/public/theme/tokyo_night.css +189 -0
  124. data/public/v0.3.0/css/houseaccount.css +1 -0
  125. data/public/v0.3.0/font/bootstrap-icons-6LILWPE2.woff2 +0 -0
  126. data/public/v0.3.0/font/quicksand-500-latin-QHYSRFVK.woff2 +0 -0
  127. data/public/v0.3.0/font/quicksand-500-latin-ext-QZ4PXVHB.woff2 +0 -0
  128. data/public/v0.3.0/font/quicksand-500-latin-ext.woff2 +0 -0
  129. data/public/v0.3.0/font/quicksand-500-latin.woff2 +0 -0
  130. data/public/v0.3.0/font/quicksand-500-vietnamese-RRA5CUQB.woff2 +0 -0
  131. data/public/v0.3.0/font/quicksand-500-vietnamese.woff2 +0 -0
  132. data/public/v0.3.0/js/houseaccount.js +144 -0
  133. data/public/v0.3.0/theme/bootstrap.css +11 -0
  134. data/public/v0.3.0/theme/dawn.css +189 -0
  135. data/public/v0.3.0/theme/dracula.css +189 -0
  136. data/public/v0.3.0/theme/gruvbox.css +189 -0
  137. data/public/v0.3.0/theme/monokai.css +190 -0
  138. data/public/v0.3.0/theme/nord.css +189 -0
  139. data/public/v0.3.0/theme/one_dark.css +189 -0
  140. data/public/v0.3.0/theme/solarized.css +188 -0
  141. data/public/v0.3.0/theme/tokyo_night.css +189 -0
  142. metadata +190 -12
  143. data/LICENSE.txt +0 -21
data/art/icons.txt ADDED
@@ -0,0 +1,184 @@
1
+ # Every Bootstrap Icon a HouseAccount page may draw: the ones unicon names for its concepts,
2
+ # and the few written out by name in recourse and here. The icon font and its stylesheet
3
+ # are cut down to these by rake icons; a name missing here is a blank box on a page.
4
+ airplane
5
+ alarm
6
+ archive
7
+ arrow-clockwise
8
+ arrow-counterclockwise
9
+ arrow-down
10
+ arrow-left
11
+ arrow-right
12
+ arrow-up
13
+ arrows-angle-contract
14
+ arrows-angle-expand
15
+ award
16
+ bag
17
+ bank
18
+ bar-chart
19
+ battery
20
+ bell
21
+ bicycle
22
+ book
23
+ bookmark
24
+ bookmark-fill
25
+ box
26
+ box-arrow-in-right
27
+ box-arrow-right
28
+ briefcase
29
+ brush
30
+ building
31
+ bus-front
32
+ c-circle
33
+ calendar
34
+ calendar-check
35
+ camera
36
+ camera-video
37
+ car-front
38
+ card-checklist
39
+ cart
40
+ cash
41
+ chat
42
+ check
43
+ check-circle
44
+ check-circle-fill
45
+ check2-square
46
+ chevron-down
47
+ chevron-left
48
+ chevron-right
49
+ chevron-up
50
+ circle
51
+ clipboard
52
+ clock
53
+ cloud
54
+ cloud-rain
55
+ collection-play
56
+ compass
57
+ copy
58
+ credit-card
59
+ dash
60
+ display
61
+ download
62
+ droplet
63
+ emoji-smile
64
+ envelope
65
+ exclamation-triangle
66
+ eye
67
+ eye-slash
68
+ file-earmark
69
+ file-earmark-text
70
+ fingerprint
71
+ fire
72
+ flag
73
+ folder
74
+ funnel
75
+ gear
76
+ geo
77
+ geo-alt
78
+ gift
79
+ globe
80
+ graph-up
81
+ grid
82
+ grip-vertical
83
+ hammer
84
+ hand-thumbs-down
85
+ hand-thumbs-up
86
+ hdd-rack
87
+ headphones
88
+ heart
89
+ hospital
90
+ hourglass
91
+ house
92
+ image
93
+ inbox
94
+ infinity
95
+ info-circle
96
+ key
97
+ keyboard
98
+ laptop
99
+ layout-sidebar
100
+ lightbulb
101
+ lightning
102
+ link
103
+ list
104
+ list-ul
105
+ lock
106
+ magic
107
+ map
108
+ megaphone
109
+ mic
110
+ moon
111
+ moon-fill
112
+ music-note
113
+ palette
114
+ paperclip
115
+ pause
116
+ pen
117
+ pencil
118
+ people
119
+ percent
120
+ person
121
+ person-circle
122
+ person-dash
123
+ person-plus
124
+ person-rolodex
125
+ person-vcard
126
+ phone
127
+ pie-chart
128
+ play
129
+ plus
130
+ power
131
+ printer
132
+ puzzle
133
+ qr-code
134
+ question-circle
135
+ receipt
136
+ repeat
137
+ reply
138
+ rulers
139
+ scissors
140
+ search
141
+ send
142
+ share
143
+ shield
144
+ shop
145
+ shuffle
146
+ signpost-split
147
+ skip-forward
148
+ sliders
149
+ snow
150
+ sort-down
151
+ soundwave
152
+ speedometer
153
+ square
154
+ star
155
+ sticky
156
+ stop
157
+ stopwatch
158
+ sun
159
+ sun-fill
160
+ table
161
+ tag
162
+ telephone
163
+ terminal
164
+ three-dots
165
+ ticket
166
+ tools
167
+ train-front
168
+ trash
169
+ tree
170
+ trophy
171
+ truck
172
+ unlock
173
+ upc
174
+ upload
175
+ volume-up
176
+ wallet
177
+ wifi
178
+ window
179
+ wrench
180
+ x
181
+ x-circle
182
+ x-lg
183
+ zoom-in
184
+ zoom-out
@@ -0,0 +1,12 @@
1
+ en:
2
+ houseaccount:
3
+ ago: '%{time} ago'
4
+ ask: Ask a question
5
+ close: Close
6
+ okay: Okay
7
+ one_time_code: |-
8
+ Enter this 6-digit code to proceed: %{pin}
9
+
10
+ @%{host} #%{pin}
11
+ send: Send
12
+ typing: Typing
@@ -0,0 +1,19 @@
1
+ require 'rack/files'
2
+
3
+ module HouseAccount
4
+ # Answers `/down.html` with the page the host shows while it is down: its own where it is
5
+ # one of the family, the house's otherwise.
6
+ class Down
7
+ def initialize(app)
8
+ @app = app
9
+ @files = Rack::Files.new Engine.root.join('public/down').to_s
10
+ end
11
+
12
+ # The page where the path asks for it, and the host otherwise.
13
+ def call(env)
14
+ return @app.call env unless env['PATH_INFO'] == '/down.html'
15
+
16
+ @files.call env.merge('PATH_INFO' => "/#{HouseAccount.app}.html")
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,53 @@
1
+ # rails/engine alone raises NoMethodError: railtie.rb calls delegate_missing_to too early.
2
+ require 'rails'
3
+
4
+ module HouseAccount
5
+ # Hooks the gem into a host app: serves its files, its error pages and its helpers.
6
+ class Engine < ::Rails::Engine
7
+ # The folders the design site publishes, served from this gem at the same paths so a
8
+ # page on an app and a page on the site link one file — this version's copy under its
9
+ # own prefix among them. Each keeps its slash: `Rack::Static` matches on `start_with?`,
10
+ # and `/page` would swallow `/pages` itself.
11
+ PREFIXES = %W[/css/ /js/ /theme/ /font/ /logo/ /favicon/ /page/ /v#{VERSION}/].freeze
12
+
13
+ # An unversioned file is asked for again on every full load and answered `304 Not
14
+ # Modified` while it stands, since its URL never changes between versions. A font, whose
15
+ # name carries a hash of its bytes, and anything under this version's prefix never change
16
+ # at all, so a browser may keep them for good.
17
+ HEADERS = [
18
+ [:all, { 'cache-control' => 'no-cache' }],
19
+ ['/font/', { 'cache-control' => 'public, max-age=31536000, immutable' }],
20
+ ['/v', { 'cache-control' => 'public, max-age=31536000, immutable' }],
21
+ ].freeze
22
+
23
+ # What a file is kept for while the gem is being worked on: not at all, since the
24
+ # version's copy is rewritten under the same name until it is released, and a browser
25
+ # told to keep it for a year would show yesterday's stylesheet on today's page.
26
+ DEVELOPMENT_HEADERS = HEADERS.first 1
27
+
28
+ # Before `Rails::Rack::Logger`, so fetching a stylesheet writes no `Started GET` line, and
29
+ # cascading, so a host route under one of these prefixes still answers.
30
+ initializer 'houseaccount.assets' do |app|
31
+ headers = Rails.env.development? ? DEVELOPMENT_HEADERS : HEADERS
32
+ app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
33
+ urls: PREFIXES, root: Engine.root.join('public').to_s,
34
+ header_rules: headers, cascade: true
35
+ end
36
+
37
+ # The branded error pages, read by status from `public/page` the way Rails reads a host's
38
+ # own `public/`, unless the host has already named an app of its own.
39
+ initializer 'houseaccount.exceptions' do |app|
40
+ app.config.exceptions_app ||=
41
+ ActionDispatch::PublicExceptions.new Engine.root.join('public/page').to_s
42
+ end
43
+
44
+ # The page a host shows while it is down, at the path Heroku and a reader expect.
45
+ initializer 'houseaccount.down' do |app|
46
+ app.middleware.insert_before Rails::Rack::Logger, Down
47
+ end
48
+
49
+ initializer 'houseaccount.helpers' do
50
+ ActiveSupport.on_load(:action_view) { include HouseAccount::Helpers }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,30 @@
1
+ require 'action_view'
2
+
3
+ module HouseAccount
4
+ class FormBuilder < ActionView::Helpers::FormBuilder
5
+ # The field Rails has none of: the six slots a code is typed into.
6
+ module Pins
7
+ # What the one real PIN field carries: digits only, six of them, offered by the phone from
8
+ # the text that brought them, and drawn as slots by Bootstrap and the `otp` controller.
9
+ PIN = {
10
+ class: 'otp-input', inputmode: :numeric, pattern: '[0-9]{6}', minlength: 6, maxlength: 6,
11
+ autocomplete: 'one-time-code',
12
+ }.freeze
13
+
14
+ # `method` as Bootstrap's OTP input: one real field rendered as six slots, the slots
15
+ # reddened where the record refused the code. Every option is the field's. The field is
16
+ # drawn by the view rather than this builder on purpose, so it is never dressed as a
17
+ # `form-control`: a refused one would wear a red box of its own around slots that say so.
18
+ def pin_field(method, **options)
19
+ invalid = @object.respond_to?(:errors) && @object.errors.include?(method)
20
+ classes = @template.class_names 'otp otp-lg', 'is-invalid': invalid
21
+
22
+ field = with_classes PIN.merge(options), PIN[:class]
23
+
24
+ @template.tag.div class: classes, data: { controller: 'otp', bs_otp: true } do
25
+ @template.text_field @object_name, method, **field, object: @object
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,84 @@
1
+ require 'action_view'
2
+ require_relative 'form_builder/pins'
3
+
4
+ module HouseAccount
5
+ # The house's form: what a label, a field and a button of one wear, and the field Rails has
6
+ # none of. Given to one form as `builder:`, or to every form of a controller through
7
+ # `default_form_builder`. What a view passes is kept: its classes join the house's, its
8
+ # `data` is merged key by key with the house's, and any other option of its overrides.
9
+ class FormBuilder < ActionView::Helpers::FormBuilder
10
+ include Pins
11
+
12
+ # What a field wears: Bootstrap's control, at the size a thumb finds.
13
+ CONTROL = 'form-control form-control-lg'
14
+
15
+ # What a button wears: the large pill in the primary color, solid or outlined.
16
+ PILL = 'btn btn-lg rounded-5 theme-primary'
17
+
18
+ # What clears a button of the field or the button above it.
19
+ SPACE = 'mt-3 md:mt-4'
20
+
21
+ # What a phone field carries: the bundle's `phone` controller, which shapes the number as
22
+ # it is typed, and the keyboard and autofill a phone offers for one.
23
+ PHONE = {
24
+ autocomplete: 'tel',
25
+ data: { controller: 'phone', action: 'keydown->phone#down input->phone#input' },
26
+ }.freeze
27
+
28
+ # The fieldset a form's controls sit in: a grid one column wide, so each takes the whole
29
+ # row, with `legend` over them as a `form-label` where one is given. Every option is the
30
+ # fieldset's.
31
+ def fieldset(legend = nil, **options, &)
32
+ heading = (@template.tag.legend legend, class: 'form-label' if legend)
33
+ inside = @template.safe_join [heading, @template.capture(&)].compact
34
+
35
+ @template.tag.fieldset inside, **with_classes(options, 'd-grid')
36
+ end
37
+
38
+ # A label is Bootstrap's `form-label`, whatever else it is given.
39
+ def label(method, text = nil, options = {}, &)
40
+ return label(method, nil, text, &) if text.is_a? Hash
41
+
42
+ super(method, text, with_classes(options, 'form-label'), &)
43
+ end
44
+
45
+ # A text field is a large control.
46
+ def text_field(method, options = {}) = super(method, with_classes(options, CONTROL))
47
+
48
+ # A phone field is a large control shaped by the `phone` controller as it is typed.
49
+ def phone_field(method, options = {})
50
+ super(method, PHONE.deep_merge(with_classes(options, CONTROL)))
51
+ end
52
+
53
+ # A submit is the house's button, solid.
54
+ def submit(value = nil, options = {})
55
+ super(value, with_classes(options, "#{PILL} btn-solid #{SPACE}"))
56
+ end
57
+
58
+ # A button is the house's too: solid where it submits, which is what one does unless told
59
+ # `type: :button`, and outlined where it does not. A disabled one given a `title` says why
60
+ # on hover — `Available soon` — from a wrapper around it, since a disabled button hears no
61
+ # mouse; the wrapper then takes the button's place in the column.
62
+ def button(value = nil, options = {}, &)
63
+ return button(nil, value, &) if value.is_a? Hash
64
+
65
+ wrapped = options[:disabled] && options[:title]
66
+ fill = options[:type].to_s == 'button' ? 'btn-outline' : 'btn-solid'
67
+ classes = @template.class_names PILL, fill, (SPACE unless wrapped)
68
+ button = super(value, with_classes(options.except(:title), classes), &)
69
+
70
+ wrapped ? tooltipped(button, options[:title]) : button
71
+ end
72
+
73
+ private
74
+
75
+ def tooltipped(button, title)
76
+ @template.tag.span button, class: "d-grid #{SPACE}",
77
+ data: { controller: 'tooltip', bs_title: title }
78
+ end
79
+
80
+ def with_classes(options, classes)
81
+ options.merge class: @template.class_names(classes, options[:class])
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,49 @@
1
+ module HouseAccount
2
+ module Helpers
3
+ module Chats
4
+ # Who sent a message, when, whether it arrived and what it cost, small above its bubble.
5
+ module Asides
6
+ # Which bubble: the other party's comes in on the left, the reader's goes out on the right.
7
+ SIDES = { left: 'in', right: 'out' }.freeze
8
+
9
+ # How a delivery reads, and in which of Bootstrap's tones: a mark for arrived, a mark
10
+ # for sent and not yet, a cross for either way it failed.
11
+ DELIVERIES = {
12
+ delivered: %w[✓ success], sent: %w[✓ secondary],
13
+ failed: %w[✕ danger], undelivered: %w[✕ danger],
14
+ }.freeze
15
+
16
+ private
17
+
18
+ def houseaccount_chat_aside(message)
19
+ parts = [
20
+ message.sender, houseaccount_chat_time(message.at),
21
+ houseaccount_chat_delivery(message.delivery), houseaccount_chat_cost(message.cost),
22
+ ]
23
+ return if parts.compact.empty?
24
+
25
+ side = SIDES.fetch message.side
26
+ tag.small safe_join(parts.compact, ' '), class: "chat-aside chat-aside-#{side}"
27
+ end
28
+
29
+ def houseaccount_chat_time(at)
30
+ return unless at
31
+
32
+ tag.time t('houseaccount.ago', time: time_ago_in_words(at)),
33
+ datetime: at.xmlschema, title: l(at, format: :long)
34
+ end
35
+
36
+ def houseaccount_chat_delivery(delivery)
37
+ return unless delivery
38
+
39
+ mark, tone = DELIVERIES.fetch delivery.to_sym
40
+ tag.strong mark, class: "fg-#{tone}"
41
+ end
42
+
43
+ def houseaccount_chat_cost(cost)
44
+ "· #{number_to_currency cost, precision: 4}" if cost
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,40 @@
1
+ module HouseAccount
2
+ module Helpers
3
+ module Chats
4
+ # The field under a thread that sends the next question.
5
+ module Asks
6
+ # A solid button in the primary, rounded like the field beside it.
7
+ SEND_CLASSES = 'btn btn-solid rounded-3 theme-primary ms-3'
8
+
9
+ private
10
+
11
+ # `replace`, so the redirect back to the page the form is on is a page refresh to Turbo
12
+ # — morphed in place, scroll kept — rather than a visit that lands at the top.
13
+ def houseaccount_chat_ask(url, hints)
14
+ data = { controller: 'require', turbo_action: 'replace' }
15
+ form_with url:, class: 'd-flex align-items-center', data: do
16
+ safe_join [
17
+ tag.label(t('houseaccount.ask'), for: 'ask', class: 'visually-hidden'),
18
+ text_field_tag(:ask, nil, **houseaccount_chat_ask_options(hints)),
19
+ submit_tag(t('houseaccount.send'), class: SEND_CLASSES),
20
+ ], "\n"
21
+ end
22
+ end
23
+
24
+ def houseaccount_chat_ask_options(hints)
25
+ {
26
+ id: 'ask', required: true, autofocus: true, autocomplete: 'off',
27
+ class: 'form-control rounded-4 flex-grow-1 chat-ask', placeholder: hints.first,
28
+ data: { thread_target: 'field', **houseaccount_chat_placeholder_data(hints) },
29
+ }
30
+ end
31
+
32
+ def houseaccount_chat_placeholder_data(hints)
33
+ return {} if hints.size < 2
34
+
35
+ { controller: 'placeholder', placeholder_questions_value: hints.to_json }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ require_relative 'chats/asides'
2
+ require_relative 'chats/asks'
3
+
4
+ module HouseAccount
5
+ module Helpers
6
+ # A conversation drawn as the bubbles every HouseAccount chat wears.
7
+ module Chats
8
+ include Asides, Asks
9
+
10
+ # `messages` as a thread that opens at its newest and, where `url` is given, a field
11
+ # under it that posts the next message there, suggesting `hints` one at a time.
12
+ def chat_with(messages:, url: nil, hints: [])
13
+ parts = [houseaccount_chat_messages(messages)]
14
+ parts.push houseaccount_chat_ask(url, hints) if url
15
+
16
+ tag.div safe_join(parts, "\n"), class: 'chat-thread', data: { controller: 'thread' }
17
+ end
18
+
19
+ private
20
+
21
+ def houseaccount_chat_messages(messages)
22
+ tag.div class: 'chat', data: { thread_target: 'messages' } do
23
+ safe_join messages.map { |message| houseaccount_chat_message message }, "\n"
24
+ end
25
+ end
26
+
27
+ def houseaccount_chat_message(message)
28
+ parts = [houseaccount_chat_aside(message), houseaccount_chat_bubble(message)]
29
+
30
+ safe_join parts.compact, "\n"
31
+ end
32
+
33
+ def houseaccount_chat_bubble(message)
34
+ side = Asides::SIDES.fetch message.side
35
+ return tag.p message.text, class: "chat-bubble chat-#{side}" if message.text
36
+
37
+ tag.p safe_join(Array.new(3) { tag.span }), class: "chat-bubble chat-#{side} chat-typing",
38
+ aria: { label: t('houseaccount.typing') }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,47 @@
1
+ module HouseAccount
2
+ module Helpers
3
+ # A dialog a page opens from a link, with none of Bootstrap's markup written by hand.
4
+ module Dialogs
5
+ # A link reading `text` and the dialog it opens: headed `title` with an X to close it,
6
+ # holding the block, and closed as well by a footer button reading `okay` — `Okay`
7
+ # unless told otherwise, or none at all for `false`. The two share an id made from the
8
+ # link's own words, so a page needs to invent none; Bootstrap's own `data-bs-toggle`
9
+ # does the opening, and the browser's element brings the backdrop, the focus trap and
10
+ # Esc. Any other option is an attribute of the link.
11
+ def dialog(text, title:, okay: t('houseaccount.okay'), **options, &)
12
+ id = "dialog-#{text.parameterize}"
13
+ data = { **options.fetch(:data, {}), bs_toggle: 'dialog', bs_target: "##{id}" }
14
+ parts = [houseaccount_dialog_header(id, title), tag.div(capture(&), class: 'dialog-body')]
15
+ parts.push houseaccount_dialog_footer(okay) if okay
16
+
17
+ safe_join [
18
+ link_to(text, "##{id}", **options, data:),
19
+ tag.dialog(safe_join(parts), class: 'dialog dialog-slide-down', id:,
20
+ aria: { labelledby: "#{id}-title" }),
21
+ ]
22
+ end
23
+
24
+ private
25
+
26
+ def houseaccount_dialog_header(id, title)
27
+ tag.div class: 'dialog-header' do
28
+ heading = tag.h2 title, class: 'dialog-title', id: "#{id}-title"
29
+
30
+ safe_join [heading, houseaccount_dialog_close]
31
+ end
32
+ end
33
+
34
+ def houseaccount_dialog_close
35
+ tag.button type: 'button', class: 'btn-close ms-auto', data: { bs_dismiss: 'dialog' },
36
+ aria: { label: t('houseaccount.close') }
37
+ end
38
+
39
+ def houseaccount_dialog_footer(okay)
40
+ tag.div class: 'dialog-footer' do
41
+ tag.button okay, type: 'button', class: 'btn btn-solid theme-secondary',
42
+ data: { bs_dismiss: 'dialog' }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,45 @@
1
+ module HouseAccount
2
+ module Helpers
3
+ # The page of a signup flow: the mark over the wall, a title, a line, one card, the way out.
4
+ module Flows
5
+ # How wide the card is unless a page says otherwise: one column of a form, on a desktop.
6
+ WIDTH = '30rem'
7
+
8
+ # Draws the page around the card's content, standing on the wall: the mark linking to
9
+ # `logo` over what the view said in `content_for` — its `:title`, its `:subtitle`, how
10
+ # wide the card is in `:width` — and the pieces of `footer` under the card joined by a
11
+ # middot. No subtitle draws no line and an empty footer no foot: a page says only what
12
+ # it has.
13
+ def flow(footer: [], logo: Logos::HOME, &)
14
+ parts = [
15
+ tag.header(houseaccount_logo(href: logo)),
16
+ *houseaccount_flow_words,
17
+ houseaccount_flow_card(content_for(:width), &),
18
+ houseaccount_flow_footer(footer),
19
+ ]
20
+
21
+ tag.div safe_join(parts.compact), class: 'flow', data: { controller: 'wall' }
22
+ end
23
+
24
+ private
25
+
26
+ def houseaccount_flow_words
27
+ [tag.h1(content_for(:title)), (tag.p content_for :subtitle if content_for? :subtitle)]
28
+ end
29
+
30
+ def houseaccount_flow_card(width, &)
31
+ width = width.presence || WIDTH
32
+ style = "--flow-width: #{width}" unless width == WIDTH
33
+
34
+ tag.div capture(&), class: 'flow-card p-3 md:p-4 mx-auto', style:
35
+ end
36
+
37
+ def houseaccount_flow_footer(pieces)
38
+ pieces = Array(pieces).compact_blank
39
+ return if pieces.empty?
40
+
41
+ tag.footer safe_join(pieces, tag.span('·', class: 'fg-2', aria: { hidden: true }))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,42 @@
1
+ module HouseAccount
2
+ module Helpers
3
+ # What every HouseAccount page puts in its `<head>`.
4
+ module Heads
5
+ # One line per tag, each at the depth a head's children sit at in a layout indented
6
+ # by two: `<html>`, then `<head>`, then these.
7
+ INDENT = "\n "
8
+
9
+ # The stylesheet and the script every page needs, and the icons this app is known
10
+ # by. Both files at this version's own path, which never changes and so is kept for
11
+ # a year. A page refresh Turbo is sent morphs the page in place and keeps the scroll,
12
+ # so an answer landing in a thread leaves the reader where they were.
13
+ def houseaccount_head_tags
14
+ safe_join [
15
+ tag.meta(name: 'viewport', content: 'width=device-width, initial-scale=1'),
16
+ tag.meta(name: 'color-scheme', content: 'light dark'),
17
+ tag.meta(name: 'turbo-refresh-method', content: 'morph'),
18
+ tag.meta(name: 'turbo-refresh-scroll', content: 'preserve'),
19
+ tag.link(rel: 'stylesheet', href: "/v#{VERSION}/css/houseaccount.css"),
20
+ tag.script('', type: 'module', src: "/v#{VERSION}/js/houseaccount.js"),
21
+ *houseaccount_icon_tags,
22
+ ], INDENT
23
+ end
24
+
25
+ # An asset on the design site by its path, for a page this engine does not serve.
26
+ def houseaccount_url(path) = "#{HouseAccount.url}/#{path}"
27
+
28
+ private
29
+
30
+ def houseaccount_icon_tags
31
+ app = HouseAccount.app
32
+ mark = Rails.env.development? ? "#{app}-dev" : app
33
+ [
34
+ tag.link(rel: 'icon', href: "/favicon/#{mark}.svg", type: 'image/svg+xml'),
35
+ tag.link(rel: 'icon', href: "/favicon/#{app}-96.png", type: 'image/png', sizes: '96x96'),
36
+ tag.link(rel: 'apple-touch-icon', href: "/favicon/#{app}-180.png"),
37
+ tag.link(rel: 'manifest', href: "/favicon/#{app}.webmanifest"),
38
+ ]
39
+ end
40
+ end
41
+ end
42
+ end