recourse 4.4.4 → 4.6.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +6 -1
  4. data/app/javascript/recourse/density_controller.js +17 -0
  5. data/app/javascript/recourse/scheme_controller.js +64 -16
  6. data/app/stylesheets/recourse/themes/bootstrap.css +11 -0
  7. data/app/stylesheets/recourse/themes/dawn.css +189 -0
  8. data/app/stylesheets/recourse/themes/dracula.css +189 -0
  9. data/app/stylesheets/recourse/themes/gruvbox.css +189 -0
  10. data/app/stylesheets/recourse/themes/monokai.css +190 -0
  11. data/app/stylesheets/recourse/themes/nord.css +189 -0
  12. data/app/stylesheets/recourse/themes/one_dark.css +189 -0
  13. data/app/stylesheets/recourse/themes/solarized.css +188 -0
  14. data/app/stylesheets/recourse/themes/tokyo_night.css +189 -0
  15. data/app/views/layouts/recourses.html.erb +105 -83
  16. data/app/views/recourses/_footer.html.erb +3 -2
  17. data/app/views/recourses/_scheme.html.erb +18 -4
  18. data/app/views/recourses/_sidebar.html.erb +18 -1
  19. data/config/locales/recourse.en.yml +7 -2
  20. data/lib/recourse/colors.rb +7 -1
  21. data/lib/recourse/controllers.rb +31 -4
  22. data/lib/recourse/densities.rb +7 -0
  23. data/lib/recourse/engine.rb +6 -2
  24. data/lib/recourse/helpers/bookmarks.rb +3 -1
  25. data/lib/recourse/helpers/densities.rb +17 -0
  26. data/lib/recourse/helpers/formats.rb +11 -5
  27. data/lib/recourse/helpers/navigation.rb +5 -3
  28. data/lib/recourse/helpers/themes.rb +36 -0
  29. data/lib/recourse/helpers.rb +4 -3
  30. data/lib/recourse/routes/nested.rb +4 -3
  31. data/lib/recourse/themes.rb +49 -0
  32. data/lib/recourse/version.rb +1 -1
  33. data/lib/recourse.rb +2 -1
  34. metadata +17 -5
  35. data/lib/recourse/helpers/schemes.rb +0 -14
  36. data/lib/recourse/schemes.rb +0 -6
@@ -15,7 +15,9 @@
15
15
  <%# Preloaded from the head instead of discovered at the end of the body, so the
16
16
  module the toast controller and the dismiss X import is already in flight. -%>
17
17
  <link rel='modulepreload' href='/recourse/bootstrap.bundle.min.js'>
18
- <%# After the stylesheet, which is what makes it win: both selectors are `:root`. -%>
18
+ <%# After the Bootstrap link, which is what makes either win: every selector involved
19
+ is `:root`. The palette the host named, then the one the reader chose last time. -%>
20
+ <%= theme_stylesheet_link %>
19
21
  <%= render 'recourses/scheme' %>
20
22
  <%= primary_color_style %>
21
23
 
@@ -34,6 +36,7 @@
34
36
  import BookmarkController from '/recourse/bookmark_controller.js'
35
37
  import ClearController from '/recourse/clear_controller.js'
36
38
  import ComboboxController from '/recourse/combobox_controller.js'
39
+ import DensityController from '/recourse/density_controller.js'
37
40
  import DeselectController from '/recourse/deselect_controller.js'
38
41
  import LimitController from '/recourse/limit_controller.js'
39
42
  import PhoneController from '/recourse/phone_controller.js'
@@ -54,6 +57,7 @@
54
57
  window.Stimulus.register('bookmark', BookmarkController)
55
58
  window.Stimulus.register('clear', ClearController)
56
59
  window.Stimulus.register('combobox', ComboboxController)
60
+ window.Stimulus.register('density', DensityController)
57
61
  window.Stimulus.register('deselect', DeselectController)
58
62
  window.Stimulus.register('limit', LimitController)
59
63
  window.Stimulus.register('phone', PhoneController)
@@ -118,11 +122,9 @@
118
122
  .nav-link:hover:not(.active) { background-color: transparent; }
119
123
  }
120
124
 
121
- /* The navbar stacks over the sidebar, and both over the main column: a menu
122
- opened from a filter drops over the sidebar's row of links on a phone and over
123
- the first rows of the table anywhere, rather than under either. */
124
- .recourse-sidebar { position: relative; z-index: 2; }
125
- .recourse-shell > nav { position: relative; z-index: 3; }
125
+ /* The navbar stacks over the content: a menu opened from a filter drops over the
126
+ first rows of the table rather than under them. */
127
+ .recourse-shell > nav { position: relative; z-index: 1; }
126
128
 
127
129
  /* An action column is as wide as a row is tall — an icon in a square — and a
128
130
  counter column starts at the same square, widening only when a figure like
@@ -203,43 +205,37 @@
203
205
  min-height: 0;
204
206
  }
205
207
 
206
- /* A long page scrolls its content, not its chrome. Above the width the sidebar
207
- becomes a column at, the shell is exactly the viewport and `main` is the only
208
- thing inside it that scrolls, so the navbar and the sidebar stay where they
209
- are however far down the table a reader is. Two things make it hold. Every step
210
- between takes `min-height: 0`, a flex child being as tall as its content unless
211
- told otherwise. And the row stops wrapping: a wrapped line is as tall as the
212
- tallest thing on it, and stretching only ever grows an item to the line, never
213
- shrinks it to one so `main` kept its own height and took the page with it.
214
- Below that width the sidebar is a band across the top, the row wraps to stack
215
- the two, and the page scrolls as one, which is the only thing that reads on a
216
- phone.
217
- Named by the path down to it rather than as any `.row` in the shell: a show
218
- page lays its values out in one and a form lays its fields out in another, and
219
- `flex-wrap: nowrap` reaches those too as a descendant which is three `col-6`
220
- values squeezed onto one line instead of two rows of two. Only the row holding
221
- the sidebar and the content is meant here, and only it is a child of the
222
- container.
223
- `main` is the only thing that scrolls inside the shell, and the sidebar
224
- deliberately is not. A window dragged shorter than the sidebar itself is the one
225
- case the viewport cannot hold, and there the sidebar is allowed to be taller
226
- than the line it was stretched to, spill past the shell, and let the browser
227
- scroll the page — which is what a reader expects of something taller than the
228
- screen. Were it to scroll on its own instead, the same overflow would be hidden
229
- inside it and the page would look whole while its foot was unreachable. */
208
+ /* The shell is three things the sidebar, the navbar, the content written in
209
+ the order a phone reads them: a row of icons at the very top, the trail and the
210
+ search under it, the page under that, scrolling as one. From the width the
211
+ sidebar becomes a column at, a grid seats them the other way round. The navbar
212
+ runs across the top, the sidebar takes the column under it and the content the
213
+ rest, the shell is exactly the viewport, and `main` is the only thing in it that
214
+ scrolls so the navbar and the sidebar stay where they are however far down the
215
+ table a reader is. `minmax(0, 1fr)` is what lets the second row be shorter than
216
+ what is in it; a `1fr` alone is as tall as its content, and the page would scroll
217
+ after all.
218
+ The sidebar deliberately does not scroll. A window dragged shorter than the
219
+ sidebar itself is the one case the viewport cannot hold, and there the sidebar
220
+ is allowed to spill past the shell and let the browser scroll the page which is
221
+ what a reader expects of something taller than the screen. Were it to scroll on
222
+ its own instead, the same overflow would be hidden inside it and the page would
223
+ look whole while its foot was unreachable. */
230
224
  @media (width >= 768px) {
231
- .recourse-shell { min-height: 0; height: 100dvh; }
232
- .recourse-shell > .container-fluid,
233
- .recourse-shell > .container-fluid > .row { min-height: 0; }
234
- .recourse-shell > .container-fluid > .row { flex-wrap: nowrap; }
235
- .recourse-shell main { overflow-y: auto; }
236
- /* Stretched to the line like any item, but never squeezed under its own
237
- content: the floor is what carries the rule below down past the fold. */
238
- .recourse-shell .recourse-sidebar { min-height: min-content; }
225
+ .recourse-shell {
226
+ display: grid;
227
+ grid-template-columns: auto minmax(0, 1fr);
228
+ grid-template-rows: auto minmax(0, 1fr);
229
+ grid-template-areas: 'nav nav' 'aside main';
230
+ height: 100dvh;
231
+ }
232
+ .recourse-shell > nav { grid-area: nav; }
233
+ .recourse-sidebar { grid-area: aside; }
234
+ .recourse-shell > main { grid-area: main; overflow-y: auto; }
239
235
  }
240
236
 
241
- /* The sidebar rules off the content below it while it sits under the navbar,
242
- and off the content beside it once it is a column again. v6 generates no
237
+ /* The sidebar rules off the navbar below it while it is the band at the top of a
238
+ phone, and off the content beside it once it is a column. v6 generates no
243
239
  responsive border utilities, so the switch is written out. */
244
240
  .recourse-sidebar {
245
241
  border-block-end: var(--bs-border-width) solid var(--bs-border-color);
@@ -265,6 +261,10 @@
265
261
  next to one, so it takes the line's height and not `.btn`'s own minimum —
266
262
  which would leave the row under every table taller than the words in it. */
267
263
  .recourse-limit { min-height: 0; }
264
+ /* The bookmark square is an icon in a row of text, not a control in a form, so it
265
+ takes the line's height too: `.btn`'s minimum made every row with one taller
266
+ than the rows without. */
267
+ .recourse-bookmark { min-height: 0; }
268
268
  /* Stacked, each control is a row of its own rather than a fragment of one:
269
269
  there is no second control beside it to share the line with. */
270
270
  @media (width < 768px) {
@@ -278,6 +278,13 @@
278
278
  small controls are 14px, and so is the body on a phone, so the controls say
279
279
  16px themselves and focusing one moves nothing. */
280
280
  .recourse-search .form-control { font-size: 16px; }
281
+ /* A page under a record has a trail two or three crumbs long, and what sat beside
282
+ the trail — the search, its filters, a button — was squeezed into what those
283
+ left: `Filter by na`. Under such a trail each takes a row of its own, the form
284
+ the whole width and every control in it a row in turn, and the margin that set
285
+ a button apart from the trail goes, there being no trail beside it. An index at
286
+ the top keeps its form beside its one crumb, where it fits. */
287
+ .recourse-trailed > :not(nav) { flex: 0 0 100%; margin-inline-start: 0; }
281
288
  }
282
289
 
283
290
  /* Held Option marks the letter each sidebar link answers to. Marked rather than
@@ -404,20 +411,29 @@
404
411
  .recourse-foot .nav-link:hover { background-color: var(--bs-bg-1); color: var(--bs-fg-body); }
405
412
  }
406
413
 
414
+ /* `.nav-item` ships `flex: auto`: in a row every entry would take an equal share
415
+ of the width and in a full-height column an equal share of the height, and the
416
+ foot's auto margin would never have anything left to push against. Every item is
417
+ its own size instead, in both directions. */
418
+ .recourse-sidebar .nav-item { flex: 0 0 auto; }
419
+
420
+ /* On a phone the sidebar is one row of icons across the top, centered, each entry
421
+ with less room around it than a column gives — a dozen have to fit — and wrapping
422
+ to a second line only where a host has more than a screen's width of them. */
423
+ @media (width < 768px) {
424
+ .recourse-sidebar .nav { justify-content: center; --bs-nav-link-padding-x: .5rem; }
425
+ }
426
+
407
427
  /* The foot sits at the bottom of the sidebar and stretches across it while the
408
428
  sidebar is a column, and is simply the last item of the row while it is not
409
- — the query matches the `md:` the sidebar's own classes use. Both lines are
410
- needed: `.nav-item` ships `flex: auto`, so every item would take an equal share
411
- of a full-height nav and the auto margin would never have anything left to
412
- push against. Pin the items to their own height and the foot takes the rest. */
429
+ — the query matches the `md:` the sidebar's own classes use. */
413
430
  @media (width >= 768px) {
414
431
  /* A floor rather than a height, and never wrapped. `.nav` ships `flex-wrap:
415
432
  wrap`, and a column that may wrap, whose items are pinned to their own height
416
- by the line below, and which is told exactly how tall to be has one way to
417
- place a link that does not fit: a second column. Which is what a short window
418
- used to draw. The floor lets it be taller than the space it was given. */
433
+ above, and which is told exactly how tall to be has one way to place a link
434
+ that does not fit: a second column. Which is what a short window used to draw.
435
+ The floor lets it be taller than the space it was given. */
419
436
  .recourse-sidebar .nav { min-height: 100%; flex-wrap: nowrap; }
420
- .recourse-sidebar .nav-item { flex: 0 0 auto; }
421
437
  /* And it rides at the foot of the screen, which the auto margin alone is
422
438
  enough to do: the nav's floor is the sidebar's full height, so while the
423
439
  links leave room the margin takes all of it and the foot sits on the fold,
@@ -434,31 +450,40 @@
434
450
  }
435
451
  }
436
452
 
437
- /* A phone's card has room for a row of tabs only as pictures and figures: Show
438
- and Edit are their icons, a counted tab its icon and its number. A tab with
439
- neither keeps its word, there being nothing else to stand for it. */
440
- @media (width < 768px) {
441
- .recourse-tab-word { display: none; }
442
- }
443
-
444
- /* And the trail at the top: a resource's crumb is its icon alone on a phone, so the
445
- search box and the button beside it have the row. A record's crumb has no icon
446
- and keeps its words. */
453
+ /* A phone reads the chrome as pictures. A sidebar entry is its icon, a crumb its
454
+ icon, a tab its icon and its figure `17` rather than `17 franchises` — and the
455
+ foot's controls their icons, so the sidebar is one row of squares and the trail
456
+ leaves the search its room. The words are there all the same, read but not seen
457
+ the way Bootstrap's `.visually-hidden` does it, and the arrows at the foot put
458
+ them back: the cookie they write has the server draw the shell `.recourse-expanded`,
459
+ and then none of this applies. A tab or a crumb with no icon keeps its word at
460
+ every width, there being nothing else to stand for it.
461
+ The same hiding, at every width, for the foot's words once the sidebar is a
462
+ column: there is room for an icon and not for a word beside it. */
463
+ .recourse-foot-word { margin-inline-start: .4em; }
447
464
  @media (width < 768px) {
448
- .recourse-crumb-word { display: none; }
465
+ .recourse-shell:not(.recourse-expanded) :is(.recourse-nav-word, .recourse-crumb-word,
466
+ .recourse-tab-word, .recourse-foot-word) {
467
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
468
+ overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
469
+ }
449
470
  }
450
-
451
- /* While the sidebar is a row its foot has room for words, so each icon is followed
452
- by its own; as a column the words are read but not seen, the way Bootstrap's
453
- `.visually-hidden` does it. */
454
- .recourse-foot-word { margin-inline-start: .4em; }
455
471
  @media (width >= 768px) {
456
472
  .recourse-foot-word {
457
473
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
458
474
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
459
475
  }
476
+ /* Nothing to expand: a column has its words already. */
477
+ .recourse-density { display: none; }
460
478
  }
461
479
 
480
+ /* The arrows say where a tap goes, like the moon: parting, with `Expand`, while
481
+ the chrome is icons; meeting, with `Compact`, once the words are out. Both faces
482
+ are drawn and the shell's class picks, so the button needs no script to redraw. */
483
+ .recourse-density-expanded { display: none; }
484
+ .recourse-expanded .recourse-density-compact { display: none; }
485
+ .recourse-expanded .recourse-density-expanded { display: inline; }
486
+
462
487
  /* The toggle draws both icons and shows the one that names where a click
463
488
  would go: a moon on a light page, a sun on a dark one. Three states rather than
464
489
  two, because a page nobody has chosen for carries no attribute at all, and the
@@ -486,35 +511,32 @@
486
511
 
487
512
  <%# The reader's zone is told to the server here rather than from a page of its
488
513
  own: every screen the gem draws is one to read against their clock. -%>
489
- <body class='recourse-shell d-flex flex-column min-vh-100'
514
+ <body class='<%= class_names 'recourse-shell', 'recourse-expanded': expanded? %>'
490
515
  data-controller='timezone' data-timezone-storage-value='<%= Recourse::ZONE_STORAGE %>'>
516
+ <%# First in the page, since a phone reads it first: the row of icons at the top,
517
+ above the trail. From 768px the layout's grid seats it beside the content instead,
518
+ under the navbar, whatever order it was written in. -%>
519
+ <aside class='recourse-sidebar py-3 px-3'>
520
+ <%= render 'recourses/sidebar' %>
521
+ </aside>
522
+
491
523
  <nav class='navbar border-bottom'>
492
524
  <%# `row-gap` rather than a margin on the search: a margin is there whether the
493
525
  form wrapped or not, and on the line it shares with the breadcrumb it drops
494
- the field a few pixels below it. A row gap only exists between lines. -%>
495
- <div class='container-fluid px-3 justify-content-start row-gap-2'>
526
+ the field a few pixels below it. A row gap only exists between lines. Under a
527
+ trail of more than one crumb a phone gives the form and the buttons rows of
528
+ their own, which `recourse-trailed` is what the stylesheet reads. -%>
529
+ <div class='<%= class_names 'container-fluid px-3 justify-content-start row-gap-2',
530
+ 'recourse-trailed': resource_breadcrumbs.size > 1 %>'>
496
531
  <%= render 'recourses/breadcrumb' %>
497
532
  <%= yield :actions %>
498
533
  <%= yield :search %>
499
534
  </div>
500
535
  </nav>
501
536
 
502
- <div class='container-fluid flex-grow-1 d-flex'>
503
- <%# Stacked, the sidebar and the content are two lines of a wrapping flex row,
504
- and `align-content: stretch` would hand each of them half the height the
505
- page has left over — an empty gap under a dozen links. Packed to the top
506
- below 768px, and stretched again above it, where the single line being full
507
- height is what draws the sidebar's rule the whole way down. -%>
508
- <div class='row flex-grow-1 align-content-start md:align-content-stretch'>
509
- <aside class='recourse-sidebar col-12 md:col-auto py-3 px-3 mx-0'>
510
- <%= render 'recourses/sidebar' %>
511
- </aside>
512
-
513
- <main class='col-12 md:col py-4 px-3'>
514
- <%= yield %>
515
- </main>
516
- </div>
517
- </div>
537
+ <main class='py-4 px-3'>
538
+ <%= yield %>
539
+ </main>
518
540
 
519
541
  <%= render 'recourses/flash' %>
520
542
  <%= render 'recourses/confirm' %>
@@ -1,13 +1,14 @@
1
1
  <%# locals: (pagy:) %>
2
2
  <% if pagy %>
3
- <%# A column on a phone, the sentence above the pages, and one row from `sm` up. -%>
3
+ <%# A column on a phone, the sentence centered above the pages the way the pages are
4
+ centered under it, and one row from `sm` up, the sentence at its start. -%>
4
5
  <div class='d-flex flex-column sm:flex-row sm:align-items-center' <%= tag.attributes data: limit_data(pagy) %>>
5
6
  <%# The gem's own sentence from pagy's public readers, every figure delimited —
6
7
  rewriting pagy's English with a regex delimited one figure, only in English.
7
8
  The switch follows it after a dot: how much of the table is showing, then the
8
9
  one other amount it could show. Only while there is a second page — a table
9
10
  that fits on one is not being paginated, and there is nothing to switch. -%>
10
- <div class='ps-2 flex-grow-1'>
11
+ <div class='ps-2 flex-grow-1 text-center sm:text-start'>
11
12
  <% if pagy.last > 1 %>
12
13
  <%= t 'recourse.displaying_slice', from: number_with_delimiter(pagy.from),
13
14
  to: number_with_delimiter(pagy.to),
@@ -1,14 +1,28 @@
1
- <%# The mode a reader chose last time, back before anything is painted. Classic and
2
- inline rather than a module or a Stimulus controller: both of those run after the
3
- first paint, which would show the system's mode for an instant and then swap it. -%>
1
+ <%# The palette and mode a reader chose last time, back before anything is painted.
2
+ Classic and inline rather than a module or a Stimulus controller: both of those run
3
+ after the first paint, which would show the palette the server chose for an instant
4
+ and then swap it. The name is checked against the shape a palette's name has before
5
+ it reaches a URL — the storage is the reader's own, but a value read back out of it
6
+ is still not a value to trust. -%>
4
7
  <script>
5
8
  (function () {
6
9
  var stored
7
10
  try { stored = JSON.parse(localStorage.getItem('<%= Recourse::SCHEME_STORAGE %>')) }
8
11
  catch (error) { return }
12
+ if (!stored) { return }
9
13
 
10
- if (stored && (stored.mode === 'light' || stored.mode === 'dark')) {
14
+ if (stored.mode === 'light' || stored.mode === 'dark') {
11
15
  document.documentElement.dataset.bsTheme = stored.mode
12
16
  }
17
+ if (!/^[a-z_]+$/.test(stored.theme || '')) { return }
18
+
19
+ var link = document.querySelector('link[data-recourse-theme]')
20
+ if (!link) {
21
+ link = document.createElement('link')
22
+ link.rel = 'stylesheet'
23
+ link.dataset.recourseTheme = ''
24
+ document.head.appendChild(link)
25
+ }
26
+ link.href = '<%= Recourse::THEMES_PATH %>/' + stored.theme + '.css'
13
27
  })()
14
28
  </script>
@@ -17,7 +17,24 @@
17
17
  is which of the two mode icons shows — the mode may be the system's, which only CSS
18
18
  knows, so both are drawn. -%>
19
19
  <li class='nav-item recourse-foot'>
20
- <%# In a wrapper of its own, as the way out is in its form: the wrapper is the half
20
+ <%# For a phone alone, whose chrome is icons until a reader asks for the words: a tap
21
+ puts them back beside every icon, and the next takes them away. Both faces are
22
+ drawn — arrows parting to expand, meeting to compact — and the shell's class shows
23
+ the one that says where a tap goes, the way the moon and the sun are both drawn. -%>
24
+ <div class='recourse-density'>
25
+ <%= tag.button type: 'button', class: 'nav-link border-0 bg-transparent',
26
+ data: density_data do %>
27
+ <span class='recourse-density-compact'>
28
+ <%= icon_tag :expand %>
29
+ <span class='recourse-foot-word'><%= t 'recourse.expand' %></span>
30
+ </span>
31
+ <span class='recourse-density-expanded'>
32
+ <%= icon_tag :collapse %>
33
+ <span class='recourse-foot-word'><%= t 'recourse.compact' %></span>
34
+ </span>
35
+ <% end %>
36
+ </div>
37
+ <%# In a wrapper of its own, as the way out is in its form: the wrapper is the share
21
38
  of the row, and the button is only as wide as its icon. -%>
22
39
  <div>
23
40
  <%= tag.button type: 'button', class: 'nav-link border-0 bg-transparent',
@@ -18,10 +18,11 @@ en:
18
18
  bookmark_removed: Bookmark removed
19
19
  cancel: Cancel
20
20
  clear: Clear search
21
+ compact: Compact
21
22
  create: Create
22
23
  created: "%{model} was created."
23
24
  created_error: "%{model} could not be created."
24
- darken: Dark mode
25
+ darken: Dark
25
26
  delete: Delete %{model}
26
27
  deleted: "%{model} was deleted."
27
28
  exit: Exit
@@ -30,6 +31,7 @@ en:
30
31
  other: Displaying %{count} items
31
32
  displaying_slice: Displaying items %{from}-%{to} of %{total}
32
33
  edit: Edit
34
+ expand: Expand
33
35
  format: Please match the format %{example}
34
36
  from_now: "in %{distance}"
35
37
  # How many values a list holds, which is what its summary reads before anybody
@@ -37,7 +39,7 @@ en:
37
39
  items:
38
40
  one: 1 item
39
41
  other: "%{count} items"
40
- lighten: Light mode
42
+ lighten: Light
41
43
  missing_model: You declared `recourses :%{name}` in your routes file, but this app
42
44
  has no %{model} model.
43
45
  nested: You nested `recourses %{names}` inside `resources :%{parent}`, which does
@@ -45,6 +47,7 @@ en:
45
47
  # What a menu with several picks reads as, the first named and the rest counted.
46
48
  more: "%{first} + %{count} more"
47
49
  new: New %{model}
50
+ new_tab: Open in a new tab
48
51
  no_results: No results found
49
52
  none: "No %{models}."
50
53
  optional: Optional
@@ -60,6 +63,8 @@ en:
60
63
  unbookmark: Remove bookmark
61
64
  unknown_color: "`Recourse.color` is one of %{colors}, or nil for Bootstrap's own
62
65
  blue. It cannot be %{color}."
66
+ unknown_theme: "`Recourse.theme` is one of %{themes}, or nil for Bootstrap's own
67
+ palette. It cannot be %{theme}."
63
68
  updated: "%{model} was updated."
64
69
  updated_error: "%{model} could not be updated."
65
70
  unset: None
@@ -28,5 +28,11 @@ module Recourse
28
28
  end
29
29
 
30
30
  # Which text a family's 500 step carries, named as the token upstream spells it with.
31
- def self.ink(family) = DARK_INKS.include?(family) ? 'gray-975' : 'white'
31
+ # A palette repaints the step, so it may move a family across the line and answers
32
+ # first where it does.
33
+ def self.ink(family)
34
+ inks = THEMES[theme] || DARK_INKS
35
+
36
+ inks.include?(family) ? 'gray-975' : 'white'
37
+ end
32
38
  end
@@ -1,16 +1,43 @@
1
1
  module Recourse
2
2
  # Defines the controller classes a host app has not written for itself.
3
3
  module Controllers
4
+ @held = []
5
+ @lock = Mutex.new
6
+
4
7
  # Creates a controller for the named resource unless the host app has one. The
5
8
  # name arrives with whatever namespace it was drawn in — `admin/contacts` — so
6
- # the constant lands where Rails will look for it.
7
- def self.define_missing(path, base = nil)
9
+ # the constant lands where Rails will look for it. A block names the superclass,
10
+ # `RecoursesController` by default, and is called only once the class is made.
11
+ def self.define_missing(path, &base)
8
12
  class_name = "#{path.camelize}Controller"
9
13
  # const_defined? is true for a Zeitwerk autoload, so files on disk count too.
10
14
  return if Object.const_defined? class_name
11
15
 
12
- namespace(class_name.deconstantize).const_set class_name.demodulize,
13
- Class.new(base || RecoursesController)
16
+ booting? ? hold(class_name, base) : define(class_name, base)
17
+ end
18
+
19
+ # Makes the controllers held back while the app booted. The executor runs this
20
+ # ahead of the app's first request or job, once every class may be loaded.
21
+ def self.define_held
22
+ @lock.synchronize { define(*@held.shift) until @held.empty? }
23
+ end
24
+
25
+ private_class_method def self.define(class_name, base)
26
+ superclass = base ? base.call : RecoursesController
27
+ namespace(class_name.deconstantize).const_set class_name.demodulize, Class.new(superclass)
28
+ end
29
+
30
+ # Rails 8.2 warns when Action Controller is loaded during the boot of an app that
31
+ # will not eager load — a rake task's, which draws its routes before it is done
32
+ # and never dispatches to a controller — so the class waits for the first run.
33
+ private_class_method def self.booting?
34
+ app = Rails.application
35
+ app.present? && !app.config.eager_load && !app.initialized?
36
+ end
37
+
38
+ private_class_method def self.hold(class_name, base)
39
+ Rails.application.executor.to_run { Controllers.define_held } if @held.empty?
40
+ @held << [class_name, base]
14
41
  end
15
42
 
16
43
  # The module a namespaced controller belongs in, made where the host has none of
@@ -0,0 +1,7 @@
1
+ # Reopened for the one thing a reader says about how much a phone spells out.
2
+ module Recourse
3
+ # Where a reader's say on a phone's chrome is kept: a cookie, since the server reads it
4
+ # back and draws the words in or leaves them out, rather than drawing and then hiding.
5
+ # `expanded` puts the words beside every icon; anything else, or nothing, is compact.
6
+ DENSITY_STORAGE = 'recourse-density'
7
+ end
@@ -23,7 +23,8 @@ module Recourse
23
23
  # prefix keeps its slash: without it `/recourses` would be served as a file.
24
24
  # Turbo answers first, by exact path — matched on the prefix alone, the statics
25
25
  # below would swallow it with a 404 instead of passing it on. Then vendored
26
- # files, cascading so our own JavaScript shares the URL. All of it sits before
26
+ # files, then our own JavaScript, each cascading so the next shares the URL, and
27
+ # finally our own stylesheets — the palettes. All of it sits before
27
28
  # `Rails::Rack::Logger`, so fetching a stylesheet writes no `Started GET` line:
28
29
  # a file is not what a log is about, and placement says so without the gem
29
30
  # touching a host's logging.
@@ -38,7 +39,10 @@ module Recourse
38
39
  urls: STATIC_URLS, root: Engine.root.join('vendor'),
39
40
  cascade: true
40
41
  app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
41
- urls: STATIC_URLS, root: Engine.root.join('app/javascript')
42
+ urls: STATIC_URLS, root: Engine.root.join('app/javascript'),
43
+ cascade: true
44
+ app.middleware.insert_before Rails::Rack::Logger, Rack::Static,
45
+ urls: STATIC_URLS, root: Engine.root.join('app/stylesheets')
42
46
  end
43
47
  end
44
48
  end
@@ -50,13 +50,15 @@ module Recourse
50
50
 
51
51
  # Kept or not, one square either way: the same path with the verb reversed, so
52
52
  # the button toggles by flipping the method Rails already wrote into the form.
53
+ # `recourse-bookmark` takes back the height `.btn` claims, so the row stays as
54
+ # tall as one with no square in it.
53
55
  def bookmark_button(record)
54
56
  kept = bookmarked_ids.include? record.id
55
57
  label = t "recourse.#{kept ? 'unbookmark' : 'bookmark'}"
56
58
 
57
59
  button_to bookmark_icon(kept, label), bookmark_url(record),
58
60
  method: kept ? :delete : :post, form_class: 'd-inline-block',
59
- class: 'btn btn-sm btn-link p-0 border-0 lh-1',
61
+ class: 'btn btn-sm btn-link p-0 border-0 lh-1 recourse-bookmark',
60
62
  **bookmark_data(kept, label)
61
63
  end
62
64
 
@@ -0,0 +1,17 @@
1
+ module Recourse
2
+ module Helpers
3
+ # Whether a phone reads the chrome as icons alone or as icons with their words.
4
+ module Densities
5
+ private
6
+
7
+ # Whether the reader asked for the words: the cookie the arrows write, read back so
8
+ # the page arrives expanded rather than compact and then widened by a script.
9
+ def expanded? = cookies[Recourse::DENSITY_STORAGE] == 'expanded'
10
+
11
+ # What the arrows need to switch: the class they toggle and the cookie they write.
12
+ def density_data
13
+ { controller: 'density', action: 'density#toggle', density_storage_value: Recourse::DENSITY_STORAGE }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -79,15 +79,21 @@ module Recourse
79
79
  value.is_a?(String) && value.match?(WEB_URL)
80
80
  end
81
81
 
82
- # Bootstrap's icon link, saying the value leads somewhere the way text cannot.
83
- # What it reads is the host, and an ellipsis where the address goes further --
84
- # a path is how a machine finds the page, and the href is already carrying it.
82
+ # The words lead where the value points, in this tab, the way any link does; the
83
+ # arrow after them Bootstrap's icon link, stepping under the cursor opens the
84
+ # same address in a new tab. Either reads as the host, and an ellipsis where the
85
+ # address goes further: a path is how a machine finds the page, and the href has it.
85
86
  def url_link(value)
86
87
  host, rest = value.match(WEB_URL).captures
87
88
  said = rest.delete_suffix('/').empty? ? host : "#{host}/…"
88
89
 
89
- tag.a safe_join([said, icon_tag(:point_right)], ' '),
90
- href: value, class: 'icon-link icon-link-hover'
90
+ safe_join [tag.a(said, href: value), new_tab_arrow(value)], ' '
91
+ end
92
+
93
+ def new_tab_arrow(value)
94
+ tag.a icon_tag(:point_right), href: value, target: '_blank', rel: 'noopener',
95
+ class: 'icon-link icon-link-hover',
96
+ aria: { label: t('recourse.new_tab') }
91
97
  end
92
98
  end
93
99
  end
@@ -54,13 +54,15 @@ module Recourse
54
54
  new_resource_path.nil? && routed_action?('create')
55
55
  end
56
56
 
57
- # Label for a link to a resource: the icon its model picked, then its title,
58
- # with the letter at `key` marked where the link answers to one.
57
+ # Label for a sidebar link: the icon its model picked, then its title, with the
58
+ # letter at `key` marked where the link answers to one. The words are wrapped where
59
+ # an icon stands beside them, so a phone can keep the icon and drop them.
59
60
  def resource_label(resource, title, key = nil)
60
61
  name = key ? shortcut_title(title, key) : title
61
62
  icon = Recourse.known_icon resource
63
+ return name unless icon
62
64
 
63
- safe_join [icon && tag.i(class: "bi bi-#{icon}"), name].compact, ' '
65
+ safe_join [tag.i(class: "bi bi-#{icon}"), tag.span(name, class: 'recourse-nav-word')], ' '
64
66
  end
65
67
 
66
68
  # The same for a crumb, whose words a phone drops where the icon stands for them:
@@ -0,0 +1,36 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The color scheme a host named, and the reader's own say in which one it is.
4
+ module Themes
5
+ private
6
+
7
+ # The stylesheet that repaints Bootstrap's ramps in `Recourse.theme`, or nothing at
8
+ # all where none is set — the stylesheet's own palette is already there. A file
9
+ # rather than a block in the page: it is the same bytes on every request, so a
10
+ # browser is asked for it once, and one attribute swap moves the whole page to
11
+ # another palette. After the Bootstrap link and never before it, both blocks being
12
+ # `:root`, so it wins on being later.
13
+ def theme_stylesheet_link
14
+ theme = Recourse.theme
15
+ return unless theme
16
+
17
+ tag.link rel: 'stylesheet', href: theme_stylesheet_path(theme),
18
+ data: { recourse_theme: '' }
19
+ end
20
+
21
+ # Where one palette is served from.
22
+ def theme_stylesheet_path(theme) = "#{Recourse::THEMES_PATH}/#{theme}.css"
23
+
24
+ # What the sidebar's toggle needs to rotate: every palette there is, where they
25
+ # are served from, and where to keep what the reader picked.
26
+ def scheme_data
27
+ {
28
+ controller: 'scheme', action: 'scheme#rotate',
29
+ scheme_themes_value: Recourse::THEMES.keys.to_json,
30
+ scheme_path_value: Recourse::THEMES_PATH,
31
+ scheme_storage_value: Recourse::SCHEME_STORAGE,
32
+ }
33
+ end
34
+ end
35
+ end
36
+ end