bh 6.1.4 → 6.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +87 -15
  4. data/app/javascript/bh/bookmark_controller.js +97 -0
  5. data/app/javascript/bh/clear_controller.js +22 -0
  6. data/app/javascript/bh/combobox/menu.js +93 -0
  7. data/app/javascript/bh/combobox_controller.js +82 -0
  8. data/app/javascript/bh/confirm.js +100 -0
  9. data/app/javascript/bh/density_controller.js +21 -0
  10. data/app/javascript/bh/deselect_controller.js +37 -0
  11. data/app/javascript/bh/flash.js +35 -0
  12. data/app/javascript/bh/limit_controller.js +23 -0
  13. data/app/javascript/bh/map/loader.js +20 -0
  14. data/app/javascript/bh/map_controller.js +88 -0
  15. data/app/javascript/bh/otp/slots.js +27 -0
  16. data/app/javascript/bh/otp_controller.js +98 -0
  17. data/app/javascript/bh/phone_controller.js +89 -0
  18. data/app/javascript/bh/placeholder_controller.js +28 -0
  19. data/app/javascript/bh/relative_time_controller.js +45 -0
  20. data/app/javascript/bh/require_controller.js +18 -0
  21. data/app/javascript/bh/reveal_controller.js +16 -0
  22. data/app/javascript/bh/scheme_controller.js +99 -0
  23. data/app/javascript/bh/search_controller.js +78 -0
  24. data/app/javascript/bh/shortcuts_controller.js +42 -0
  25. data/app/javascript/bh/sortable_controller.js +84 -0
  26. data/app/javascript/bh/thread_controller.js +47 -0
  27. data/app/javascript/bh/timezone_controller.js +27 -0
  28. data/app/javascript/bh/toast_controller.js +35 -0
  29. data/app/javascript/bh/tooltip_controller.js +23 -0
  30. data/app/javascript/bh/written.js +20 -0
  31. data/app/javascript/bh/written_controller.js +28 -0
  32. data/app/javascript/bh.js +60 -0
  33. data/app/stylesheets/bh/base.css +27 -0
  34. data/app/stylesheets/bh/chat.css +77 -0
  35. data/app/stylesheets/bh/flow.css +94 -0
  36. data/app/stylesheets/bh/map.css +3 -0
  37. data/app/stylesheets/bh/pin.css +34 -0
  38. data/app/stylesheets/bh/search.css +60 -0
  39. data/app/stylesheets/bh/shell.css +202 -0
  40. data/app/stylesheets/bh/table.css +130 -0
  41. data/app/stylesheets/bh/values.css +41 -0
  42. data/app/stylesheets/bh.css +13 -0
  43. data/build.mjs +15 -0
  44. data/config/locales/bh.en.yml +13 -0
  45. data/lib/bh/engine.rb +21 -6
  46. data/lib/bh/form_builder/comboboxes.rb +38 -0
  47. data/lib/bh/form_builder/controls.rb +67 -0
  48. data/lib/bh/form_builder/pins.rb +30 -0
  49. data/lib/bh/form_builder.rb +96 -0
  50. data/lib/bh/helpers/chats/asides.rb +49 -0
  51. data/lib/bh/helpers/chats/asks.rb +41 -0
  52. data/lib/bh/helpers/chats.rb +42 -0
  53. data/lib/bh/helpers/dialogs.rb +47 -0
  54. data/lib/bh/helpers/flows.rb +49 -0
  55. data/lib/bh/helpers/heads.rb +27 -0
  56. data/lib/bh/helpers/notices.rb +55 -0
  57. data/lib/bh/helpers.rb +12 -0
  58. data/lib/bh/message.rb +11 -0
  59. data/lib/bh/version.rb +2 -3
  60. data/lib/bh.rb +8 -6
  61. data/package.json +32 -0
  62. data/public/bh/css/bh.css +1 -0
  63. data/public/bh/js/bh.js +152 -0
  64. data/vendor/bootstrap.bundle.min.js +9 -0
  65. data/vendor/bootstrap.min.css +2 -0
  66. metadata +101 -23
  67. data/.gitignore +0 -23
  68. data/.rspec +0 -3
  69. data/.travis.yml +0 -15
  70. data/.yardopts +0 -3
  71. data/Gemfile +0 -4
  72. data/app/assets/stylesheets/bh.css +0 -29
  73. data/app/javascript/controllers/bh/theme_controller.js +0 -16
  74. data/app/javascript/controllers/phone_controller.js +0 -33
  75. data/app/javascript/controllers/require_controller.js +0 -20
  76. data/app/javascript/controllers/submit_controller.js +0 -24
  77. data/app/views/bh/_grid.html.erb +0 -6
  78. data/app/views/bh/_table.html.erb +0 -44
  79. data/bh.gemspec +0 -19
  80. data/lib/bh/bootstrap_helpers.rb +0 -137
@@ -0,0 +1,42 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Option is the modifier. Control and Command are spoken for — Control+C and
4
+ // Command+C are both copy — while Option is what a browser's own accesskey reaches
5
+ // for on most platforms, and it collides with almost nothing a page would want.
6
+ export default class extends Controller {
7
+ connect() {
8
+ this.reveal = (event) => this.element.classList.toggle('recourse-keys', event.altKey)
9
+ this.hide = () => this.element.classList.remove('recourse-keys')
10
+ this.press = (event) => this.#press(event)
11
+
12
+ document.addEventListener('keydown', this.reveal)
13
+ document.addEventListener('keyup', this.reveal)
14
+ document.addEventListener('keydown', this.press)
15
+ // Holding Option and switching windows would otherwise leave the hints up.
16
+ window.addEventListener('blur', this.hide)
17
+ }
18
+
19
+ disconnect() {
20
+ document.removeEventListener('keydown', this.reveal)
21
+ document.removeEventListener('keyup', this.reveal)
22
+ document.removeEventListener('keydown', this.press)
23
+ window.removeEventListener('blur', this.hide)
24
+ }
25
+
26
+ #press(event) {
27
+ if (!event.altKey || event.ctrlKey || event.metaKey) { return }
28
+
29
+ // On a Mac, Option+c is 'ç', so the key pressed is read from the keyboard's own
30
+ // layout-independent code rather than from the character it produced.
31
+ const pressed = event.code.match(/^Key([A-Z])$/)
32
+ if (!pressed) { return }
33
+
34
+ const link = this.element.querySelector(`[data-key="${pressed[1].toLowerCase()}"]`)
35
+ if (!link) { return }
36
+
37
+ event.preventDefault()
38
+ this.hide()
39
+ // Clicked rather than assigned to, so Turbo takes the visit like any other link.
40
+ link.click()
41
+ }
42
+ }
@@ -0,0 +1,84 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import Sortable from 'sortablejs'
3
+ import { flash } from './flash.js'
4
+
5
+ // Drags a row of an arranged table to another place in it. Only the row that moved is
6
+ // reported, as the place it landed in: the server shifts whatever it displaced,
7
+ // because it is the only one that knows how many rows there are behind this page.
8
+ //
9
+ // The rows are a page rather than the table, so the index a drop reports is short by
10
+ // whatever the pages before it hold — `offset` is what makes up the difference, and a
11
+ // drag therefore moves a row within the page it is on.
12
+ export default class extends Controller {
13
+ static values = { offset: Number, message: String }
14
+
15
+ connect() {
16
+ this.sortable = Sortable.create(this.element, {
17
+ handle: '.recourse-handle',
18
+ animation: 150,
19
+ onStart: this.hold,
20
+ onEnd: this.drop,
21
+ })
22
+ // A refresh broadcast morphs the page, and one arriving mid-drag would rewrite the
23
+ // rows under the cursor. The search form sits a morph out the same way.
24
+ document.addEventListener('turbo:before-morph-element', this.freeze)
25
+ }
26
+
27
+ disconnect() {
28
+ this.sortable.destroy()
29
+ document.removeEventListener('turbo:before-morph-element', this.freeze)
30
+ }
31
+
32
+ // Arrows, so `this` survives being handed to a listener and the same function object
33
+ // is the one removed again.
34
+ hold = () => {
35
+ this.dragging = true
36
+ }
37
+
38
+ drop = ({ item, newIndex, oldIndex }) => {
39
+ this.dragging = false
40
+ if (newIndex === oldIndex) return
41
+
42
+ this.send(item.dataset.sortableUpdateUrl, this.offsetValue + newIndex + 1)
43
+ }
44
+
45
+ freeze = (event) => {
46
+ if (this.dragging && this.element.contains(event.target)) event.preventDefault()
47
+ }
48
+
49
+ // Nothing is rendered from the answer: the row is already where it was dropped, and
50
+ // redrawing the table under the cursor that dropped it is what this avoids. It is
51
+ // still read, so a move the server refused does not sit there looking saved.
52
+ async send(url, position) {
53
+ const body = new FormData()
54
+ body.append('position', position)
55
+
56
+ try {
57
+ const response = await fetch(url, {
58
+ method: 'PATCH',
59
+ body,
60
+ headers: { Accept: 'application/json', 'X-CSRF-Token': this.token },
61
+ })
62
+ if (!response.ok) return this.revert()
63
+
64
+ // Said only once the row is actually written. The drop moved it on the screen and
65
+ // would have moved it under a request that never landed, so this is the half of
66
+ // the report only the server can give.
67
+ flash(this.messageValue, 'theme-success')
68
+ } catch {
69
+ this.revert()
70
+ }
71
+ }
72
+
73
+ // Put the table back the way the database has it, since the row is sitting somewhere
74
+ // the server never agreed to.
75
+ revert() {
76
+ this.element.closest('turbo-frame')?.reload()
77
+ }
78
+
79
+ // The token in the head rather than one in a form: there is no form here, and the
80
+ // head's is global to the session and fresh per request.
81
+ get token() {
82
+ return document.querySelector('meta[name="csrf-token"]')?.content
83
+ }
84
+ }
@@ -0,0 +1,47 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // A thread opens at its newest message with the field ready, and stays at the newest as
4
+ // answers arrive. Every render undoes both -- `autofocus` included, which is why sending a
5
+ // question landed back here with nothing focused -- so it is said again on each one rather
6
+ // than only when Stimulus connects, which a morphed-in answer never triggers.
7
+ //
8
+ // The field itself Turbo is told to leave alone, since an answer landing morphs the page and
9
+ // the server's empty box would be written over a question half typed. Nothing else then
10
+ // empties it, so this does, once the question it held has actually gone.
11
+ export default class extends Controller {
12
+ static targets = ['messages', 'field']
13
+
14
+ connect() {
15
+ this.following = true
16
+ this.follow = () => { this.following = this.#atBottom() }
17
+ this.settle = () => requestAnimationFrame(() => this.#settle())
18
+ this.empty = () => { if (this.hasFieldTarget) { this.fieldTarget.value = '' } }
19
+
20
+ if (this.hasMessagesTarget) { this.messagesTarget.addEventListener('scroll', this.follow) }
21
+ document.addEventListener('turbo:render', this.settle)
22
+ this.element.addEventListener('turbo:submit-end', this.empty)
23
+ this.settle()
24
+ }
25
+
26
+ disconnect() {
27
+ if (this.hasMessagesTarget) { this.messagesTarget.removeEventListener('scroll', this.follow) }
28
+ document.removeEventListener('turbo:render', this.settle)
29
+ this.element.removeEventListener('turbo:submit-end', this.empty)
30
+ }
31
+
32
+ // Left where the reader put it once they have scrolled up to read back: an answer landing is
33
+ // no reason to pull the thread out from under them. The field takes focus without the page
34
+ // scrolling to it: a chat below the fold, the showcase's say, must not drag the page down
35
+ // on load, and a chat page is sized to the window, so there the field is in view already.
36
+ #settle() {
37
+ if (this.hasFieldTarget) { this.fieldTarget.focus({ preventScroll: true }) }
38
+ if (this.hasMessagesTarget && this.following) {
39
+ this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight
40
+ }
41
+ }
42
+
43
+ #atBottom() {
44
+ const { scrollTop, scrollHeight, clientHeight } = this.messagesTarget
45
+ return scrollHeight - scrollTop - clientHeight < 24
46
+ }
47
+ }
@@ -0,0 +1,27 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // The reader's own zone, told to the server so it can draw their pages against their
4
+ // clock. A cookie rather than local storage, for the reason the page size uses one:
5
+ // the server does the converting, and a cookie is the only storage the server is sent.
6
+ export default class extends Controller {
7
+ static values = { storage: String }
8
+
9
+ // The page in front of the reader was drawn before this cookie existed, so the first
10
+ // visit from a browser — and the first after they travel — is drawn against the
11
+ // host's zone and then asked for again. Only when the cookie disagrees, so every
12
+ // other visit costs nothing: this writes what is already there and stops.
13
+ connect() {
14
+ const zone = Intl.DateTimeFormat().resolvedOptions().timeZone
15
+ if (!zone || zone === this.#stored()) { return }
16
+
17
+ document.cookie =
18
+ `${this.storageValue}=${encodeURIComponent(zone)}; path=/; max-age=31536000; samesite=lax`
19
+ window.Turbo.visit(window.location.href, { action: 'replace' })
20
+ }
21
+
22
+ #stored() {
23
+ const row = document.cookie.split(/;\s*/).find((one) => one.startsWith(`${this.storageValue}=`))
24
+
25
+ return row ? decodeURIComponent(row.split('=')[1]) : null
26
+ }
27
+ }
@@ -0,0 +1,35 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { Toast } from 'bootstrap'
3
+ import { DELAY } from './written.js'
4
+
5
+ // The toast arrives from the server already shown, so Bootstrap's show() must never
6
+ // run: it re-adds `showing` and blinks the toast through transparent. But show() is
7
+ // also the only place Bootstrap arms its autohide, so the timer lives here instead,
8
+ // and only the hiding is Bootstrap's — the timed hide and the dismiss X then share
9
+ // one code path and one fade.
10
+ export default class extends Controller {
11
+ static values = { delay: { type: Number, default: DELAY } }
12
+
13
+ connect() {
14
+ // `autohide: false` keeps Bootstrap from arming a rival timer if anything ever
15
+ // does call show() on this element.
16
+ this.toast = Toast.getOrCreateInstance(this.element, { autohide: false })
17
+ this.startTimer()
18
+ }
19
+
20
+ disconnect() {
21
+ this.stopTimer()
22
+ this.toast.dispose()
23
+ }
24
+
25
+ // Reading the message, or aiming for the X, holds the toast open — Bootstrap's
26
+ // own pause-on-hover only guards the timer *it* armed, so it is redone here.
27
+ stopTimer() {
28
+ clearTimeout(this.timeout)
29
+ }
30
+
31
+ // A fresh full delay on leave, which is also what Bootstrap re-arms.
32
+ startTimer() {
33
+ this.timeout = setTimeout(() => this.toast.hide(), this.delayValue)
34
+ }
35
+ }
@@ -0,0 +1,23 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { Tooltip } from 'bootstrap'
3
+
4
+ // The tooltip naming an icon-only heading. Bootstrap never wires one on its own,
5
+ // and a Stimulus lifecycle is what survives Turbo redrawing the table: connect
6
+ // makes it, disconnect takes it down before the element goes, so a sorted or
7
+ // searched table never strands one over an element that left.
8
+ //
9
+ // None where nothing can hover: on a touch screen the tap that would open a link
10
+ // is also what shows a tooltip, and a word popping up over a tap reads as a hitch.
11
+ // Asked two ways, since an iPhone answered the first with a tooltip all the same: a
12
+ // screen that cannot hover, or a pointer too coarse to rest on one word of a row.
13
+ export default class extends Controller {
14
+ connect() {
15
+ if (matchMedia('(hover: none), (pointer: coarse)').matches) return
16
+
17
+ this.tooltip = Tooltip.getOrCreateInstance(this.element)
18
+ }
19
+
20
+ disconnect() {
21
+ this.tooltip?.dispose()
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ // The mark a create or an update leaves on the row it landed on, and how long the toast
2
+ // saying so stands — the one clock, which the toast controller reads too.
3
+ export const DELAY = 2000
4
+
5
+ // Marked, and handed back the way to let it go. Letting go swaps one class for the
6
+ // other rather than adding to it: the fade is an animation, and while the first class
7
+ // is still on the row it would paint the tint straight back the moment that animation
8
+ // ended. Off the row entirely, what paints it afterwards is whatever else the cascade
9
+ // says — the tint of a kept row, or nothing.
10
+ export function mark(row) {
11
+ if (!row) { return () => {} }
12
+
13
+ row.classList.remove('recourse-written-out')
14
+ row.classList.add('recourse-written')
15
+
16
+ return () => {
17
+ row.classList.remove('recourse-written')
18
+ row.classList.add('recourse-written-out')
19
+ }
20
+ }
@@ -0,0 +1,28 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+ import { mark } from './written.js'
3
+
4
+ // The row a create or an update just landed on, marked for exactly as long as the
5
+ // toast that says so. One clock rather than two: the mark goes when the message goes,
6
+ // however long somebody held the toast open by reading it.
7
+ //
8
+ // `hide`, never `hidden`. Bootstrap fires the first as the toast begins to fade and the
9
+ // second only once it has finished, a whole second later — which is a mark still lit
10
+ // over a message that has already gone.
11
+ export default class extends Controller {
12
+ static values = { row: String }
13
+
14
+ // Only where the row is on this page — a record can be written onto a page it does
15
+ // not appear on, sorted or filtered or paged away — and only where a toast is here
16
+ // to end it, since nothing else would.
17
+ connect() {
18
+ const row = document.getElementById(this.rowValue)
19
+ if (!row || !this.element.querySelector('.toast')) { return }
20
+
21
+ this.fade = mark(row)
22
+ this.element.addEventListener('hide.bs.toast', this.fade)
23
+ }
24
+
25
+ disconnect() {
26
+ if (this.fade) { this.element.removeEventListener('hide.bs.toast', this.fade) }
27
+ }
28
+ }
@@ -0,0 +1,60 @@
1
+ // Everything a page built on Bootstrap 6 runs, in one module: Turbo with the cable element
2
+ // turbo-rails signs its streams for, Bootstrap itself, and a Stimulus application with every
3
+ // controller this gem's markup names. Built by esbuild into /js/bh.js.
4
+ import '@hotwired/turbo-rails'
5
+ import 'bootstrap'
6
+ import { Application } from '@hotwired/stimulus'
7
+ import confirm from './bh/confirm.js'
8
+ import BookmarkController from './bh/bookmark_controller.js'
9
+ import ClearController from './bh/clear_controller.js'
10
+ import ComboboxController from './bh/combobox_controller.js'
11
+ import DensityController from './bh/density_controller.js'
12
+ import DeselectController from './bh/deselect_controller.js'
13
+ import LimitController from './bh/limit_controller.js'
14
+ import MapController from './bh/map_controller.js'
15
+ import OtpController from './bh/otp_controller.js'
16
+ import PhoneController from './bh/phone_controller.js'
17
+ import PlaceholderController from './bh/placeholder_controller.js'
18
+ import RelativeTimeController from './bh/relative_time_controller.js'
19
+ import RequireController from './bh/require_controller.js'
20
+ import RevealController from './bh/reveal_controller.js'
21
+ import SchemeController from './bh/scheme_controller.js'
22
+ import SearchController from './bh/search_controller.js'
23
+ import ShortcutsController from './bh/shortcuts_controller.js'
24
+ import SortableController from './bh/sortable_controller.js'
25
+ import ThreadController from './bh/thread_controller.js'
26
+ import TimezoneController from './bh/timezone_controller.js'
27
+ import ToastController from './bh/toast_controller.js'
28
+ import TooltipController from './bh/tooltip_controller.js'
29
+ import WrittenController from './bh/written_controller.js'
30
+
31
+ // Guarded: a module runs once, but a host that also loads this file from its own
32
+ // layout on a Turbo visit would otherwise connect every controller a second time.
33
+ if (!window.Stimulus) {
34
+ window.Stimulus = Application.start()
35
+ window.Stimulus.register('bookmark', BookmarkController)
36
+ window.Stimulus.register('clear', ClearController)
37
+ window.Stimulus.register('combobox', ComboboxController)
38
+ window.Stimulus.register('density', DensityController)
39
+ window.Stimulus.register('deselect', DeselectController)
40
+ window.Stimulus.register('limit', LimitController)
41
+ window.Stimulus.register('map', MapController)
42
+ window.Stimulus.register('otp', OtpController)
43
+ window.Stimulus.register('phone', PhoneController)
44
+ window.Stimulus.register('placeholder', PlaceholderController)
45
+ window.Stimulus.register('relative-time', RelativeTimeController)
46
+ window.Stimulus.register('require', RequireController)
47
+ window.Stimulus.register('reveal', RevealController)
48
+ window.Stimulus.register('scheme', SchemeController)
49
+ window.Stimulus.register('search', SearchController)
50
+ window.Stimulus.register('shortcuts', ShortcutsController)
51
+ window.Stimulus.register('sortable', SortableController)
52
+ window.Stimulus.register('thread', ThreadController)
53
+ window.Stimulus.register('timezone', TimezoneController)
54
+ window.Stimulus.register('toast', ToastController)
55
+ window.Stimulus.register('tooltip', TooltipController)
56
+ window.Stimulus.register('written', WrittenController)
57
+
58
+ // The browser's confirm() becomes a Bootstrap dialog the module draws on the first ask.
59
+ window.Turbo.config.forms.confirm = confirm
60
+ }
@@ -0,0 +1,27 @@
1
+ /* Body text is Bootstrap's own: the system face at 16px. A host wanting another sets
2
+ `--bs-body-font-family` and `--bs-body-font-size` on `:root` after this stylesheet. */
3
+
4
+ /* Bootstrap reserves the scrollbar's column on every page, scrolling or not, so pages line
5
+ up whether they grow or not; a browser with classic scrollbars then paints an empty track
6
+ down the right of a page that fits. The column comes and goes with the scrollbar here. */
7
+ :root { scrollbar-gutter: auto }
8
+
9
+ /* A dialog reads from the start whatever centers the text around the link that opened it:
10
+ `dialog` draws the two side by side, so the element inherits from the link's place. */
11
+ .dialog { text-align: start }
12
+
13
+ /* Bootstrap's .15s fade reads as vanishing. Only the hide ever transitions here
14
+ — the toast is born visible — and reduced-motion still wins, because its rule
15
+ sets `transition: none` outright rather than through this variable. */
16
+ .toast { --bs-transition-fade: opacity 1s linear; }
17
+
18
+ /* A marked match is part of the word it sits in, so it carries no padding of
19
+ its own: 'Nash' in 'Nashville' must not read as a word standing apart. */
20
+ mark { padding: 0; }
21
+
22
+ /* The icon font drops every glyph an eighth of an em to sit on a text
23
+ baseline, which inside an icon link reads as the arrow resting on the
24
+ line. A sixteenth up clears the baseline without floating — on the
25
+ ::before, so the hover transform on the .bi box itself is untouched. */
26
+ .icon-link > .bi::before { vertical-align: .0625em; }
27
+
@@ -0,0 +1,77 @@
1
+ /* A conversation as a column of bubbles: what the reader said on the right in the
2
+ primary's tint, what was said to them on the left in the page's second surface. A
3
+ chat is drawn by the `chat_with` helper and wears these. */
4
+ .chat { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1.25rem }
5
+ /* Sits at the foot of its box while short, so the first turn lands just above the field
6
+ that sends the next. An auto margin and not `align-content: end`, which once the thread
7
+ overflows pushes its first message clean out of the top with no way to scroll back. */
8
+ .chat > :first-child { margin-block-start: auto }
9
+
10
+ /* Who said it and when, small, over the bubble it belongs to and on its side. */
11
+ .chat-aside { font-size: var(--bs-font-size-sm); color: var(--bs-fg-2); margin: 0 0.75rem }
12
+ .chat-aside-out { align-self: flex-end; margin-inline-end: 1.25rem }
13
+ .chat-aside-in { align-self: flex-start }
14
+ .chat-aside .fg-success, .chat-aside .fg-danger, .chat-aside .fg-secondary { font-weight: var(--bs-font-weight-semibold) }
15
+
16
+ .chat-bubble {
17
+ max-width: 85%;
18
+ margin: 0;
19
+ /* A conversation is prose, so it reads at its own size whatever scale the host
20
+ set for itself: an admin page compacts its body type for tables dense with figures,
21
+ and a sentence somebody typed is not one of those. */
22
+ font-size: 1rem;
23
+ padding: 0.625rem 0.875rem;
24
+ border: 1px solid;
25
+ border-radius: 1rem;
26
+ /* The same sheen a message thread's bubbles wear. Set as an image so the two sides below
27
+ can name their own color without wiping it, which the `background` shorthand would. */
28
+ background-image: var(--bs-gradient);
29
+ /* An unbroken token wraps rather than widening the bubble past the phone. Whitespace is
30
+ collapsed as anywhere else: a host's line breaks arrive as markup, and the newlines and
31
+ indentation a template leaves around its words must not become room in the bubble. */
32
+ overflow-wrap: anywhere;
33
+ }
34
+ /* The reader's own turn, tinted with the primary rather than filled with it: a solid fill
35
+ under white text is what a button wears, and a question that reads as a button invites a
36
+ press. Squared at the corner nearest them, which tells the two sides apart at a glance. */
37
+ .chat-out {
38
+ align-self: flex-end;
39
+ /* Clear of the scrollbar a long thread puts down the same edge. */
40
+ margin-inline-end: 0.5rem;
41
+ color: var(--bs-primary-fg-emphasis);
42
+ background-color: var(--bs-primary-bg-subtle);
43
+ border-color: var(--bs-primary-border);
44
+ border-end-end-radius: 0.25rem;
45
+ }
46
+ /* The other side's: the page's second surface, squared at its own corner. */
47
+ .chat-in {
48
+ align-self: flex-start;
49
+ color: var(--bs-fg-1);
50
+ background-color: var(--bs-bg-2);
51
+ border-color: var(--bs-border-color);
52
+ border-end-start-radius: 0.25rem;
53
+ }
54
+
55
+ /* An answer on its way reads as one being typed. */
56
+ .chat-typing { display: flex; gap: 0.25rem; align-items: center }
57
+ .chat-typing span {
58
+ width: 0.375rem;
59
+ height: 0.375rem;
60
+ border-radius: 50%;
61
+ background: var(--bs-fg-2);
62
+ animation: chat-dot 1.2s ease-in-out infinite;
63
+ }
64
+ .chat-typing span:nth-child(2) { animation-delay: 0.15s }
65
+ .chat-typing span:nth-child(3) { animation-delay: 0.3s }
66
+ @keyframes chat-dot { 0%, 60%, 100% { opacity: 0.25 } 30% { opacity: 1 } }
67
+ @media (prefers-reduced-motion: reduce) {
68
+ .chat-typing span { animation: none; opacity: 0.5 }
69
+ }
70
+
71
+ /* The field's suggestion fades rather than cutting, so a swap mid-read is not a flicker;
72
+ the `placeholder` controller adds the class while it swaps the words. */
73
+ .chat-ask::placeholder { opacity: 1; transition: opacity 0.3s ease-in-out }
74
+ .chat-ask.is-fading::placeholder { opacity: 0 }
75
+ @media (prefers-reduced-motion: reduce) {
76
+ .chat-ask::placeholder { transition: none }
77
+ }
@@ -0,0 +1,94 @@
1
+ /* The page `flow` draws: a column taking the rest of the window so the foot rests at the
2
+ bottom of a tall screen and scrolls into view on a short one, everything centered. */
3
+ .flow { flex: 1; display: flex; flex-direction: column; text-align: center }
4
+ .flow > header { display: flex; justify-content: center; margin-bottom: 2rem }
5
+ .flow > h1 { margin: 0 0 0.75rem; font-size: clamp(1.5rem, 4vw, 2rem); font-weight: 700; letter-spacing: -0.02em }
6
+ .flow > p { margin: 0 0 1.5rem }
7
+ /* `display: contents`, so a `button_to` form stops being a box and its button becomes a flex
8
+ item, which is what lets the middot space evenly between two words. A button in the foot is
9
+ a word among words: the foot's size, no padding and no height of its own. */
10
+ .flow > footer { flex: none; margin-top: auto; padding-top: 2.5rem; display: flex; align-items: baseline; justify-content: center; gap: 0.5rem; font-size: 0.875rem }
11
+ .flow > footer form { display: contents }
12
+ .flow > footer .btn { font-size: inherit; padding: 0; min-height: 0 }
13
+ @media (max-width: 575px) { .flow > header { margin-bottom: 1.75rem } }
14
+
15
+ /* The one element on the page allowed a fill, a border and a shadow at once, because it holds
16
+ the only thing the page is for. As wide as the page said, `--flow-width`, and nine tenths of
17
+ the page color rather than `opacity`, which would fade the border and the words with it. */
18
+ .flow-card {
19
+ width: 100%;
20
+ max-width: var(--flow-width, 75rem);
21
+ background: color-mix(in srgb, var(--bs-bg-body) 90%, transparent);
22
+ border: 1px solid var(--bs-primary-border);
23
+ border-radius: 0.375rem;
24
+ box-shadow: -0.5rem 1rem 2.25rem -0.75rem rgb(6 22 40 / 40%);
25
+ }
26
+ /* Centered on the controls rather than the form, since a field does not inherit it and a
27
+ form helper may hard-code `text-start`. */
28
+ /* The card is read down the middle, and so is what a form says about itself: the legend over a
29
+ group, a submit, a single box a page is nothing but. A row is the exception -- its words and
30
+ the box under them are read from where they start, since a label centered over the box it
31
+ names leaves the eye hunting for where each one begins. */
32
+ .flow-card .form-label, .flow-card .form-control, .flow-card .invalid-feedback { text-align: center }
33
+ .flow-card form:has(.flow-field ~ .flow-field, .form-check ~ .form-check) :is(.flow-field, .form-check),
34
+ .flow-card form:has(.flow-field ~ .flow-field, .form-check ~ .form-check) .flow-field .form-label,
35
+ .flow-card form:has(.flow-field ~ .flow-field, .form-check ~ .form-check) .flow-field .form-control { text-align: start }
36
+
37
+ /* A ticked box and the words beside it are a row of two, with room between them: Bootstrap
38
+ hangs the box in the padding by a negative margin, which puts it under centered words. */
39
+ .flow-card .form-check { display: flex; align-items: start; gap: 1rem; padding-left: 0 }
40
+ .flow-card .form-check .form-check-input { flex: none; margin: 0.25em 0 0 }
41
+
42
+ /* A group of controls carries its own spacing, and twice as much under it as over: the words
43
+ over a group belong to it, and even space either side would let them drift between two. */
44
+ .flow-fieldset { margin-block: 1rem 2rem; gap: 1rem }
45
+
46
+ /* The words and the control they name, as one cell, which is what lets a form be laid out in
47
+ columns however much markup a control draws for itself. Named for the flow rather than the
48
+ form: Bootstrap has a `form-field` of its own, whose rules sized our words to themselves and
49
+ held them at the left of a row they were meant to be centered in. */
50
+ .flow-card .flow-field { display: grid; align-content: start }
51
+
52
+ /* A card wide enough for two columns takes them, and everything else in the fieldset -- the
53
+ legend, a submit -- keeps the whole row. The card is what is measured rather than the
54
+ window, so the narrow card of a sign-in page stays one column on the widest screen. */
55
+ .flow-card { container-type: inline-size }
56
+ @container (min-width: 40rem) {
57
+ .flow-card fieldset.d-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 1rem }
58
+ .flow-card fieldset.d-grid > :not(.flow-field) { grid-column: 1 / -1 }
59
+ }
60
+ /* A rule between two ways of doing the same thing, with room either side of it: a card is
61
+ short enough that a thin line needs the space to read as a divide rather than as a scratch. */
62
+ .flow-card hr { margin-block: 2rem }
63
+
64
+ /* The same edge the card wears, rather than Bootstrap's fainter default. */
65
+ .flow-card .dialog { --bs-dialog-border-color: var(--bs-primary-border) }
66
+
67
+ /* A card holding something longer than the page takes the rest of the window and scrolls that
68
+ part inside itself, so the line at its head, whatever sits at its foot and the way out under
69
+ the card stay in sight however long it gets. A thread is one such thing and says so by its
70
+ own markup; anything else -- a contract, a table -- says `flow-scroll` on the part that
71
+ scrolls. `flex: none` before the height: the column is a flex item of `main`, and `flex: 1`
72
+ would size it to its content and let the page grow and scroll instead. `min-height: 0` down
73
+ the chain, since a flex item will not shrink below its content without it. The 2rem is the
74
+ layout's `py-4`, the gutter above and below `main`. A card given a height cannot always
75
+ honour it -- a short window leaves less room than what does not scroll needs -- so it
76
+ scrolls whole rather than letting what it holds out past its own edges. */
77
+ .flow:has(.chat-thread, .flow-scroll, .flow-middle) { flex: none; height: calc(100svh - 2rem) }
78
+ .flow-card:has(.chat-thread, .flow-scroll, .flow-middle) { display: flex; flex-direction: column; flex: 1; min-height: 15rem; overflow: auto }
79
+ .flow-card .chat-thread,
80
+ .flow-card > :has(.flow-scroll) { display: flex; flex-direction: column; flex: 1; min-height: 0 }
81
+ .flow-card .chat, .flow-card .flow-scroll { flex: 1; min-height: 0; overflow-y: auto }
82
+ .flow-card .chat-ask { text-align: center }
83
+ /* A bubble reads from its start, though: a flow centers a card's words, and an answer long
84
+ enough to wrap is a paragraph, which is read down an edge rather than about a middle. */
85
+ .flow-card .chat-bubble { text-align: start }
86
+
87
+ /* A card standing as tall as those but with little in it: what it holds sits in the middle of
88
+ it, down as well as across, rather than at the top of a sheet of white. */
89
+ .flow-card .flow-middle { flex: 1; display: flex; flex-direction: column; justify-content: center }
90
+ /* On a phone the card is the page: it breaks out of the gutter with `margin-inline` rather
91
+ than a width, so it stays in the flow, and loses the side border and the corners with it. */
92
+ @media (width < 768px) {
93
+ .flow-card:has(.chat-thread) { width: auto; margin-inline: calc(50% - 50vw); padding-inline: calc(50vw - 50%); border-inline: 0; border-radius: 0 }
94
+ }
@@ -0,0 +1,3 @@
1
+ /* A map takes the height a table of a page would: tall enough to read the places
2
+ on, and short enough to leave the footer under it in view. */
3
+ .recourse-map { height: 60vh; }
@@ -0,0 +1,34 @@
1
+ /* The field a 6-digit code is typed into: one real field, drawn by Bootstrap as six slots.
2
+ `pin_field` draws the markup and the `otp` controller draws the slots. */
3
+
4
+ /* Bootstrap's OTP container is a flex row, so it has to be told to center where its card is
5
+ centered and to let the message drop onto a line of its own under the slots. `min-width: 0`,
6
+ or the grid item will not shrink below the width its slots come to, which is the whole
7
+ row hanging off the side of the card before the slots are asked to fit. */
8
+ .otp { justify-content: center; flex-wrap: wrap; min-width: 0 }
9
+ /* Six slots at a fixed width plus the five gaps between them come to 328 pixels, and a
10
+ phone's card holds 327 — so the last slot sat outside it. Sized off the row instead, they
11
+ share whatever there is and never exceed the width they were drawn at. */
12
+ .otp-slots { display: flex; justify-content: center; width: 100% }
13
+ .otp-slot { flex: 1 1 0; min-width: 0; max-width: var(--bs-otp-size) }
14
+ .otp .invalid-feedback { order: 1; flex-basis: 100%; margin-top: 0.5rem }
15
+
16
+ /* .form-control.is-invalid would outrank .otp-input on specificity and draw one red box
17
+ across all six slots; the field takes no such class, and Bootstrap reddens the slots
18
+ themselves from the container. */
19
+ .otp .otp-input.is-invalid { border: 0; box-shadow: none }
20
+
21
+ /* Bootstrap builds the six slots in JavaScript, so one box is what the server can send and
22
+ what the reader sees until they land. Held back for that moment — but on a class the
23
+ controller itself sets, so a controller that never runs leaves a field somebody can still
24
+ type a code into rather than an invisible one. */
25
+ .otp-drawing:not(.otp-rendered) .otp-input { visibility: hidden }
26
+
27
+ /* Bootstrap lays the real field transparently over the slots, so an autofilled one painted
28
+ yellow paints over them. The text stays invisible, and the fill is deferred by longer than
29
+ anybody's session rather than overridden, which is the only thing that holds it off. */
30
+ .otp-input:-webkit-autofill,
31
+ .otp-input:-webkit-autofill:focus {
32
+ -webkit-text-fill-color: transparent;
33
+ transition: background-color 0s 600000s;
34
+ }