feat refine responsive monitoring experience

Add desktop inline and mobile bottom navigation with active-state semantics. Restructure chart controls into responsive sticky desktop panels with shared timeframe dropdowns. Add the French randomized aquaponic koan card and mobile FAPG call affordance. Extend dashboard coverage for navigation, controls, and home content.

Commit
d0c9a34f192a1b20c353a731b7f6d38a7aa8fefc
Author
gpt-5.6-terra medium <kiro-ai@amazon.com>
Author date
Committer
gpt-5.6-terra medium <kiro-ai@amazon.com>
Committer date
Changed files
roles/dashboard/lib/FAPG/DAQ/Dashboard/Controller/Pages.pm
index e2fe2dbe..fea2646a 100644..100644
@@ -1,13 +1,93 @@
1 1 package FAPG::DAQ::Dashboard::Controller::Pages;
2 2 use Mojo::Base 'Mojolicious::Controller', -signatures;
3 3
4 Added: use utf8;
4 5 use POSIX qw(strftime);
5 6
7 Added: my @HOME_KOANS = (
8 Added: q{La truite demanda au basilic : qui filtre qui ?},
9 Added: q{Une tomate se pencha vers le bassin et apprit à écouter.},
10 Added: q{Quand le pH-mètre dort, les racines comptent les bulles.},
11 Added: q{La carotte ne trouva aucune rivière, mais ses feuilles étaient vertes.},
12 Added: q{Une pompe silencieuse enseigne la patience à la serre.},
13 Added: q{Le filtre rêve de pluie et se réveille propre.},
14 Added: q{Un bassin calme peut apprendre à tout un jardin à respirer.},
15 Added: q{La truite traversa la question et ne laissa aucune réponse sèche.},
16 Added: q{Une feuille tomba dans le puisard et devint demain.},
17 Added: q{Le concombre salua le biofiltre ; tous deux poussèrent plus droit.},
18 Added: q{Ne poursuis pas l’eau claire ; nourris bien les microbes.},
19 Added: q{Le basilic murmura : plus d’oxygène, moins de hâte.},
20 Added: q{Une truite sage ne discute jamais météo avec un radis.},
21 Added: q{Le caillou du bac de culture connaît toutes les saisons.},
22 Added: q{Quand le siphon cloche soupire, la laitue se souvient de la pluie.},
23 Added: q{Les racines demandèrent l’obscurité ; le soleil attendit poliment.},
24 Added: q{Une goutte d’eau contient un étang, sauf si elle contient une tomate.},
25 Added: q{Le poisson fabriqua du compost et l’appela méditation.},
26 Added: q{Le persil regarda les bulles jusqu’à devenir joyeux.},
27 Added: q{Un tuyau propre n’a rien à prouver.},
28 Added: q{La truite vit son reflet et nourrit les bactéries.},
29 Added: q{Une betterave têtue sait quand même apprécier une bonne circulation.},
30 Added: q{La pompe cessa de râler lorsque la menthe se mit à chanter.},
31 Added: q{Une feuille rouge n’est pas une alarme ; parfois c’est de la poésie.},
32 Added: q{Le bassin était plein, mais le basilic fit de la place pour l’émerveillement.},
33 Added: q{Un lombricomposteur rit de mille tableurs.},
34 Added: q{La laitue mesure le temps en nouvelles racines.},
35 Added: q{Le biofilm ne se presse jamais, et arrive pourtant partout.},
36 Added: q{La truite ne lit pas le graphique, mais connaît les matins froids.},
37 Added: q{La tomate demanda un soutien ; le treillis répondit : naturellement.},
38 Added: q{L’eau claire et les pensées troubles peuvent partager la même surface.},
39 Added: q{Le bac de culture pardonna le débordement avant le petit-déjeuner.},
40 Added: q{Une carotte dans la perlite rêve d’anciens lits de rivière.},
41 Added: q{Le nitrate monta, puis s’assit tranquillement avec le chou kale.},
42 Added: q{Une nageoire peut remuer bien plus que l’eau.},
43 Added: q{Le sage dit : vérifie la pompe. Puis vérifie ton thé.},
44 Added: q{Même le modeste bulleur fait un petit tonnerre.},
45 Added: q{Le radis poussa rond parce que personne ne lui demanda d’être carré.},
46 Added: q{Une truite et un navet se rencontrèrent en silence ; le système fonctionna.},
47 Added: q{Le pH dériva doucement, comme un nuage avec un presse-papiers.},
48 Added: q{Aucune racine n’est seule sous une eau bien aérée.},
49 Added: q{Le chou kale remercia les poissons sans faire de discours.},
50 Added: q{Le siphon cloche ne sonne que pour ceux qui cessent de le regarder.},
51 Added: q{Une bonne récolte commence par un carnet d’entretien ennuyeux.},
52 Added: q{Le ver dit : tout finit par devenir de la terre.},
53 Added: q{La fenêtre de la serre s’ouvrit ; l’humidité sourit.},
54 Added: q{Une petite bulle peut porter un très grand mardi.},
55 Added: q{L’eau devint limpide quand la fermière cessa de lui faire la morale.},
56 Added: q{La truite envisagea l’éveil, puis demanda le petit-déjeuner.},
57 Added: q{La dernière feuille de laitue salua la première goutte de pluie.},
58 Added: );
59 Added:
6 60 sub index ($self) {
61 Added: my @home_koan_masters = (
62 Added: q{Lao Chou},
63 Added: q{Truite-Zu},
64 Added: q{Maître Poireau},
65 Added: q{Sœur Carotte},
66 Added: q{Gillbert le Sage},
67 Added: q{Zen d’Endive},
68 Added: q{Maître Haricot},
69 Added: q{Sage Saumon},
70 Added: q{Dame Betterave},
71 Added: q{Cornelius la Carpe},
72 Added: q{Rōshi Radis},
73 Added: q{Frère Fenouil},
74 Added: q{Capitaine Chou-Fleur},
75 Added: q{Mademoiselle Mâche},
76 Added: q{Sœur Sardine},
77 Added: q{Maître Courgette},
78 Added: q{Vénérable Aubergine},
79 Added: q{Moine Maquereau},
80 Added: q{Léon Laitue},
81 Added: q{Professeur Petit Pois},
82 Added: );
83 Added:
7 84 $self->render(
8 Removed: template => 'dashboard/index',
9 Removed: probes => $self->probes,
10 Removed: page_title => 'Dashboard',
85 Added: template => 'dashboard/index',
86 Added: probes => $self->probes,
87 Added: home_koan => $HOME_KOANS[ int rand @HOME_KOANS ],
88 Added: home_koan_master => $home_koan_masters[ int rand @home_koan_masters ],
89 Added: nav_page => 'home',
90 Added: page_title => 'Dashboard',
11 91 );
12 92 }
13 93
roles/dashboard/public/css/app.css
index 6edf9013..043654ec 100644..100644
@@ -19,6 +19,7 @@
19 19 --fapg-border: #e2e8f0;
20 20 --fapg-text: #1e293b;
21 21 --fapg-text-muted: #64748b;
22 Added: --fapg-nav-active: #166534;
22 23 --navbar-height: 3.5rem;
23 24 }
24 25
@@ -34,6 +35,250 @@
34 35 overflow-x: clip;
35 36 }
36 37
38 Added:
39 Added: /* ----------------------------------------------------------------
40 Added: Home koan card — a small dose of aquaponic Zen
41 Added: ---------------------------------------------------------------- */
42 Added: .home-koan-card {
43 Added: inline-size: 100%;
44 Added: margin-inline: auto;
45 Added: border-color: var(--fapg-border);
46 Added: background: var(--fapg-surface);
47 Added: }
48 Added:
49 Added: @media (min-width: 768px) {
50 Added: .home-koan-card {
51 Added: inline-size: clamp(22rem, 38.2%, 32rem);
52 Added: }
53 Added: }
54 Added:
55 Added: .home-koan-card .card-body {
56 Added: padding: 0.75rem 1rem;
57 Added: }
58 Added:
59 Added: .home-koan {
60 Added: margin: 0;
61 Added: color: var(--fapg-text-muted);
62 Added: font-size: 0.9rem;
63 Added: line-height: 1.35;
64 Added: }
65 Added:
66 Added: .home-koan-text {
67 Added: font-style: italic;
68 Added: }
69 Added:
70 Added: .home-koan-attribution {
71 Added: font-size: 0.94em;
72 Added: white-space: nowrap;
73 Added: }
74 Added:
75 Added: /* ----------------------------------------------------------------
76 Added: Mobile home masthead
77 Added: ---------------------------------------------------------------- */
78 Added: @media (max-width: 767.98px) {
79 Added: .mobile-home-masthead {
80 Added: margin-bottom: 0.75rem;
81 Added: padding: 0.5rem 0;
82 Added: }
83 Added:
84 Added: .mobile-home-masthead-row {
85 Added: display: flex;
86 Added: align-items: center;
87 Added: justify-content: space-between;
88 Added: min-height: 2.75rem;
89 Added: }
90 Added:
91 Added: .mobile-home-brand {
92 Added: color: var(--fapg-nav-active);
93 Added: font-size: 1.25rem;
94 Added: font-weight: 800;
95 Added: letter-spacing: -0.02em;
96 Added: }
97 Added:
98 Added: .mobile-home-phone {
99 Added: display: inline-grid;
100 Added: place-items: center;
101 Added: width: 2.75rem;
102 Added: height: 2.75rem;
103 Added: border: 1px solid var(--fapg-border);
104 Added: border-radius: 50%;
105 Added: color: var(--fapg-nav-active);
106 Added: text-decoration: none;
107 Added: }
108 Added:
109 Added: .mobile-home-phone svg {
110 Added: width: 1.25rem;
111 Added: height: 1.25rem;
112 Added: fill: none;
113 Added: stroke: currentcolor;
114 Added: stroke-linecap: round;
115 Added: stroke-linejoin: round;
116 Added: stroke-width: 2;
117 Added: }
118 Added:
119 Added: .mobile-home-phone:focus-visible {
120 Added: outline: 3px solid #14532d;
121 Added: outline-offset: 3px;
122 Added: }
123 Added: }
124 Added: @media (min-width: 768px) {
125 Added: #navbar .navbar-nav .nav-link.active {
126 Added: color: var(--fapg-nav-active);
127 Added: }
128 Added: }
129 Added:
130 Added: /* ----------------------------------------------------------------
131 Added: Chart controls — compact mobile strip and desktop side panel
132 Added: ---------------------------------------------------------------- */
133 Added: .dashboard-page-layout,
134 Added: .dashboard-page-content {
135 Added: min-width: 0;
136 Added: }
137 Added:
138 Added: .dashboard-controls-panel {
139 Added: position: sticky;
140 Added: top: 0;
141 Added: z-index: 1015;
142 Added: display: flex;
143 Added: justify-content: center;
144 Added: margin-bottom: 1rem;
145 Added: padding: 0.5rem 0;
146 Added: background: var(--bs-body-bg, #fff);
147 Added: box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
148 Added: }
149 Added:
150 Added: .controls-panel-title {
151 Added: position: absolute;
152 Added: width: 1px;
153 Added: height: 1px;
154 Added: padding: 0;
155 Added: overflow: hidden;
156 Added: clip: rect(0, 0, 0, 0);
157 Added: white-space: nowrap;
158 Added: border: 0;
159 Added: }
160 Added:
161 Added: .graph-control {
162 Added: display: flex;
163 Added: align-items: center;
164 Added: gap: 0.5rem;
165 Added: flex-wrap: nowrap;
166 Added: }
167 Added:
168 Added: .graph-control-group {
169 Added: display: flex;
170 Added: align-items: center;
171 Added: }
172 Added:
173 Added: .graph-control-range-group {
174 Added: flex: 0 0 10rem;
175 Added: inline-size: 10rem;
176 Added: }
177 Added:
178 Added: .graph-control-range-group .range-control {
179 Added: inline-size: 100%;
180 Added: }
181 Added:
182 Added: .graph-control-range-group .range-label {
183 Added: flex: 1 1 auto;
184 Added: justify-content: center;
185 Added: }
186 Added:
187 Added: .control-group-label {
188 Added: display: none;
189 Added: }
190 Added:
191 Added: .range-control {
192 Added: flex-wrap: nowrap;
193 Added: }
194 Added:
195 Added: .range-control .range-label {
196 Added: white-space: nowrap;
197 Added: }
198 Added:
199 Added: .dashboard-page-content,
200 Added: .dashboard-page-content [id] {
201 Added: scroll-margin-block-start: 6rem;
202 Added: }
203 Added:
204 Added: @media (min-width: 768px) {
205 Added: .dashboard-page-layout {
206 Added: display: grid;
207 Added: grid-template-columns: minmax(12rem, 14rem) minmax(0, 1fr);
208 Added: gap: 1.5rem;
209 Added: align-items: start;
210 Added: }
211 Added:
212 Added: .dashboard-controls-panel {
213 Added: top: calc(var(--navbar-height) + 1rem);
214 Added: align-self: start;
215 Added: block-size: calc(100dvh - var(--navbar-height) - 2rem);
216 Added: display: block;
217 Added: margin-bottom: 0;
218 Added: padding: 1rem;
219 Added: overflow-y: auto;
220 Added: overscroll-behavior: contain;
221 Added: border: 1px solid var(--fapg-border);
222 Added: border-radius: var(--bs-border-radius);
223 Added: background: var(--fapg-surface);
224 Added: box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
225 Added: }
226 Added:
227 Added: .controls-panel-title {
228 Added: position: static;
229 Added: width: auto;
230 Added: height: auto;
231 Added: margin: 0 0 1rem;
232 Added: overflow: visible;
233 Added: clip: auto;
234 Added: color: var(--fapg-text);
235 Added: font-size: 0.9rem;
236 Added: font-weight: 700;
237 Added: line-height: 1.25;
238 Added: white-space: normal;
239 Added: }
240 Added:
241 Added: .dashboard-controls-panel .graph-control {
242 Added: align-items: stretch;
243 Added: justify-content: flex-start;
244 Added: gap: 0.75rem;
245 Added: block-size: fit-content;
246 Added: flex-direction: column;
247 Added: }
248 Added:
249 Added: .dashboard-controls-panel .graph-control-group {
250 Added: display: block;
251 Added: }
252 Added:
253 Added: .dashboard-controls-panel .graph-control-range-group {
254 Added: flex: 0 0 auto;
255 Added: inline-size: 100%;
256 Added: }
257 Added:
258 Added: .dashboard-controls-panel .control-group-label {
259 Added: display: block;
260 Added: margin-bottom: 0.35rem;
261 Added: color: var(--fapg-text-muted);
262 Added: font-size: 0.75rem;
263 Added: font-weight: 700;
264 Added: }
265 Added:
266 Added: .dashboard-controls-panel .range-control,
267 Added: .dashboard-controls-panel .timeframe-control,
268 Added: .dashboard-controls-panel .timeframe-dropdown-toggle {
269 Added: width: 100%;
270 Added: }
271 Added:
272 Added: .dashboard-controls-panel .timeframe-control .dropdown-menu {
273 Added: width: 100%;
274 Added: }
275 Added:
276 Added: .dashboard-page-content,
277 Added: .dashboard-page-content [id] {
278 Added: scroll-margin-block-start: calc(var(--navbar-height) + 1rem);
279 Added: }
280 Added: }
281 Added:
37 282 /* Toolbar graph controls — scroll horizontally without causing page overflow */
38 283 .graph-control-scroll {
39 284 max-width: 100%;
@@ -49,15 +294,6 @@
49 294 padding-bottom: 0.5rem;
50 295 }
51 296
52 Removed: .sticky-controls {
53 Removed: position: sticky;
54 Removed: top: var(--navbar-height);
55 Removed: z-index: 1015;
56 Removed: background: var(--bs-body-bg, #fff);
57 Removed: padding-top: 0.5rem;
58 Removed: padding-bottom: 0.5rem;
59 Removed: }
60 Removed:
61 297 /* Info toggle button in insight card headers */
62 298 .info-toggle {
63 299 line-height: 1;
@@ -412,4 +648,49 @@
412 648
413 649 .insight-theme {
414 650 scroll-margin-top: 5rem;
651 Added: }
652 Added:
653 Added: /* ----------------------------------------------------------------
654 Added: Mobile primary navigation
655 Added: ---------------------------------------------------------------- */
656 Added: @media (max-width: 767.98px) {
657 Added: body {
658 Added: padding-bottom: calc(4.5rem + env(safe-area-inset-bottom));
659 Added: }
660 Added: }
661 Added:
662 Added: .mobile-bottom-nav {
663 Added: position: fixed;
664 Added: z-index: 1030;
665 Added: right: 0;
666 Added: bottom: 0;
667 Added: left: 0;
668 Added: display: flex;
669 Added: min-height: calc(4.5rem + env(safe-area-inset-bottom));
670 Added: padding-bottom: env(safe-area-inset-bottom);
671 Added: border-top: 1px solid var(--fapg-border);
672 Added: background: var(--bs-body-bg, #fff);
673 Added: box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
674 Added: }
675 Added:
676 Added: .mobile-bottom-nav-link {
677 Added: display: grid;
678 Added: flex: 1 1 0;
679 Added: place-items: center;
680 Added: min-height: 4.5rem;
681 Added: padding: 0.5rem;
682 Added: color: var(--fapg-text-muted);
683 Added: font-size: 1rem;
684 Added: font-weight: 600;
685 Added: text-align: center;
686 Added: text-decoration: none;
687 Added: }
688 Added:
689 Added: .mobile-bottom-nav-link.active {
690 Added: color: var(--fapg-nav-active);
691 Added: }
692 Added:
693 Added: .mobile-bottom-nav-link:focus-visible {
694 Added: outline: 3px solid #14532d;
695 Added: outline-offset: -3px;
415 696 }
roles/dashboard/t/00-homepage.t
index ffac5bc1..d69b335c 100644..100644
@@ -11,20 +11,38 @@
11 11
12 12 $t->get_ok('/')
13 13 ->status_is(200)
14 Removed: ->element_exists('body nav.navbar + main')
15 Removed: ->text_is( 'a.navbar-brand[href="/"]', 'FAPG' )
16 Removed: ->text_is( '.navbar-nav a.nav-link.dropdown-toggle', 'Probes' )
17 Removed: ->text_is( '.navbar-nav a.nav-link[href="/insights"]', 'Insights' )
18 Removed: ->text_is( '.navbar-nav a.nav-link[href="/downloads"]', 'Downloads' )
14 Added: ->element_exists('body > main')
15 Added: ->text_is( 'a.navbar-brand[href="/"]', 'FAPG' )
16 Added: ->text_is( '.navbar-nav a.nav-link.dropdown-toggle', 'Probes' )
17 Added: ->text_is( '.navbar-nav a.nav-link[href="/insights"]', 'Insights' )
18 Added: ->text_is( '.navbar-nav a.nav-link[href="/downloads"]', 'Downloads' )
19 19 ->element_exists_not('.navbar-nav a.nav-link.active')
20 Added: ->element_exists(
21 Added: 'nav[data-mobile-navigation][aria-label="Primary navigation"]')
22 Added: ->text_is( '[data-mobile-navigation] a[href="/"]', 'Home' )
23 Added: ->element_exists(
24 Added: '[data-mobile-navigation] a.active[href="/"][aria-current="page"]')
25 Added: ->text_is( '[data-mobile-navigation] a[href="/insights"]', 'Insights' )
26 Added: ->text_is( '[data-mobile-navigation] a[href="/probes"]', 'Probes' )
27 Added: ->text_is( '[data-mobile-navigation] a[href="/weather"]', 'Weather' )
28 Added: ->element_exists_not('[data-mobile-navigation] a[href="/downloads"]')
29 Added: ->element_exists_not('button.navbar-toggler')
20 30 ->element_exists_not('nav[aria-label="Breadcrumb"]')
31 Added: ->element_exists('header[data-home-masthead]')
32 Added: ->text_is('.mobile-home-brand', 'FAPG')
33 Added: ->element_exists('figure[data-home-koan-card]')
34 Added: ->element_exists_not('figure[data-home-koan-card].d-md-none')
35 Added: ->text_like('[data-home-koan-card] .home-koan-text', qr/\S/)
36 Added: ->element_exists('[data-home-koan-card] blockquote em.home-koan-text')
37 Added: ->text_like('[data-home-koan-card] [data-home-koan-attribution]', qr/\A\x{2014}\s+\S/)
21 38 ->element_exists(
22 Removed: 'section.reading-quick-status [data-reading-quick-list]')
39 Added: 'a[data-home-phone][href="tel:+33787044193"][aria-label="Call FAPG at 07 87 04 41 93"]'
40 Added: )
41 Added: ->element_exists('section.reading-quick-status [data-reading-quick-list]')
23 42 ->element_exists_not('[data-reading-quick-status]')
24 43 ->element_exists('[data-weather-forecast]')
25 44 ->element_exists('canvas#chart-weather-forecast')
26 Removed: ->element_exists(
27 Removed: 'footer a[href="https://www.lafermeaquaponique.com/"]');
45 Added: ->element_exists('footer a[href="https://www.lafermeaquaponique.com/"]');
28 46
29 47 for my $probe (qw(ph do orp ec)) {
30 48 $t->element_exists(
roles/dashboard/t/01-graph-page.t
index b58e01fc..65edcff2 100644..100644
@@ -11,18 +11,25 @@
11 11
12 12 $t->get_ok('/probes/ph')
13 13 ->status_is(200)
14 Removed: ->element_exists('.navbar-text')
15 Removed: ->text_like('.navbar-text', qr/pH/)
16 Removed: ->element_exists('.sticky-controls .graph-control')
14 Added: ->element_exists_not('#navbar-title')
15 Added: ->element_exists(
16 Added: 'main section[data-controls-panel][aria-labelledby="probe-controls-title"]'
17 Added: )
18 Added: ->text_is( '#probe-controls-title', 'pH chart controls' )
19 Added: ->element_exists('#probe-controls .graph-control')
20 Added: ->text_is( '#probe-controls [data-control-group="range"]', 'Range' )
21 Added: ->text_is( '#probe-controls [data-control-group="timeframe"]',
22 Added: 'Timeframe' )
17 23 ->text_is( '.card-header h2 a[href="/probes/ph"]', 'pH' )
18 24 ->element_exists('.navbar-nav a.nav-link.active.dropdown-toggle')
19 25 ->element_exists('section#probe-ph[data-panel="ph"] canvas#chart-ph')
20 26 ->text_is( 'a[data-raw-download][data-probe="ph"]', 'Download data' )
21 27 ->element_exists(
22 Removed: 'button[data-timeframe="day"].timeframe-button.btn-outline-secondary'
23 Removed: )
24 Removed: ->element_exists_not('button[data-timeframe="day"].timeframe-button.btn-success')
28 Added: 'button[data-timeframe="day"].timeframe-button.dropdown-item')
25 29 ->element_exists(
30 Added: '.timeframe-control.dropdown > button[data-timeframe-dropdown-label]')
31 Added: ->element_exists_not('.timeframe-control.btn-group')
32 Added: ->element_exists(
26 33 'script[src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"]'
27 34 . '[integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"]'
28 35 )
@@ -31,8 +38,7 @@
31 38
32 39 for my $timeframe (qw(hour day week month year)) {
33 40 $t->element_exists(
34 Removed: qq{.timeframe-control button[data-timeframe="$timeframe"]}
35 Removed: );
41 Added: qq{.timeframe-control button[data-timeframe="$timeframe"]});
36 42 }
37 43
38 44 $t->get_ok('/probes/unknown')->status_is(404);
@@ -40,14 +46,19 @@
40 46
41 47 $t->get_ok('/probes')
42 48 ->status_is(200)
43 Removed: ->text_like('.navbar-text', qr/Probe Readings/)
44 Removed: ->element_exists('.sticky-controls .graph-control')
49 Added: ->element_exists(
50 Added: 'main section[data-controls-panel][aria-labelledby="probes-controls-title"]'
51 Added: )
52 Added: ->text_is( '#probes-controls-title', 'Probe chart controls' )
53 Added: ->element_exists(
54 Added: '#probes-controls .graph-control[data-default-timeframe="day"]')
45 55 ->element_exists('main.container')
46 56 ->element_exists('div.probes-grid')
47 57 ->element_exists('.navbar-nav a.nav-link.active.dropdown-toggle');
48 58
49 59 for my $probe (qw(ph do orp ec)) {
50 Removed: $t->element_exists(qq{div.probes-grid .card-header h2 a[href="/probes/$probe"]});
60 Added: $t->element_exists(
61 Added: qq{div.probes-grid .card-header h2 a[href="/probes/$probe"]});
51 62 $t->element_exists(
52 63 qq{section#probe-${probe}[data-panel="$probe"] canvas#chart-$probe});
53 64 $t->element_exists(qq{a[data-raw-download][data-probe="$probe"]});
roles/dashboard/t/05-status-page.t
index 4ffcc162..fb72f61e 100644..100644
@@ -11,7 +11,6 @@
11 11
12 12 $t->get_ok('/probes/status')
13 13 ->status_is(200)
14 Removed: ->text_like('.navbar-text', qr/Status/)
15 14 ->element_exists('main.container')
16 15 ->element_exists('.list-group');
17 16
roles/dashboard/t/06-downloads.t
index e04c5713..d469b961 100644..100644
@@ -11,21 +11,25 @@
11 11
12 12 $t->get_ok('/downloads')
13 13 ->status_is(200)
14 Removed: ->text_like('.navbar-text', qr/Downloads/)
15 Removed: ->element_exists('.navbar-nav a.nav-link.active[href="/downloads"][aria-current="page"]')
14 Added: ->element_exists(
15 Added: '.navbar-nav a.nav-link.active[href="/downloads"][aria-current="page"]')
16 16 ->element_exists('form.downloads-form[action="/downloads/readings.csv"]')
17 17 ->element_exists('[data-downloads-error]')
18 18 ->element_exists('input[name="date_range"][value="week"][checked]')
19 19 ->element_exists(
20 Removed: '[data-custom-date-fields].collapse:not(.show) input[name="from"][type="date"]')
20 Added: '[data-custom-date-fields].collapse:not(.show) input[name="from"][type="date"]'
21 Added: )
21 22 ->element_exists(
22 Removed: '[data-custom-date-fields].collapse:not(.show) input[name="to"][type="date"]')
23 Added: '[data-custom-date-fields].collapse:not(.show) input[name="to"][type="date"]'
24 Added: )
23 25 ->text_like( 'button.downloads-button[type="submit"]', qr/Download CSV/ )
24 26 ->element_exists(
25 Removed: 'button.downloads-button[type="submit"][data-download-button] [data-download-spinner].d-none')
27 Added: 'button.downloads-button[type="submit"][data-download-button] [data-download-spinner].d-none'
28 Added: )
26 29 ->text_like(
27 30 'button.downloads-button[type="submit"][formaction="/downloads/readings.xlsx"]',
28 Removed: qr/Download \.xlsx/ )
31 Added: qr/Download \.xlsx/
32 Added: )
29 33 ->element_exists(
30 34 'button.downloads-button[type="submit"][formaction="/downloads/readings.xlsx"][data-download-button] [data-download-spinner].d-none'
31 35 );
roles/dashboard/t/08-insights.t
index a026eab5..2df83e78 100644..100644
@@ -12,9 +12,19 @@
12 12 subtest 'insights page renders with all sections' => sub {
13 13 $t->get_ok('/insights')
14 14 ->status_is(200)
15 Removed: ->text_is('title', 'FAPG DAQ Insights')
16 Removed: ->text_like('.navbar-text', qr/Insights/)
15 Added: ->text_is( 'title', 'FAPG DAQ Insights' )
17 16 ->element_exists('.navbar-nav a.nav-link.active[href="/insights"]')
17 Added: ->element_exists_not('[data-home-masthead]')
18 Added: ->element_exists_not('[data-home-koan-card]')
19 Added: ->element_exists(
20 Added: '[data-mobile-navigation] a.active[href="/insights"][aria-current="page"]'
21 Added: )
22 Added: ->element_exists(
23 Added: 'main section[data-controls-panel][aria-labelledby="insights-controls-title"]'
24 Added: )
25 Added: ->text_is( '#insights-controls-title', 'Insights controls' )
26 Added: ->element_exists(
27 Added: '#insights-controls .graph-control[data-default-timeframe="day"]')
18 28 ->element_exists('[data-default-timeframe="day"]');
19 29 };
20 30
@@ -32,7 +42,7 @@
32 42 $t->get_ok('/api/insights/diurnal/do')
33 43 ->status_is(200)
34 44 ->json_has('/probe')
35 Removed: ->json_is('/probe' => 'do')
45 Added: ->json_is( '/probe' => 'do' )
36 46 ->json_has('/hours')
37 47 ->json_has('/traces')
38 48 ->json_has('/mean');
@@ -47,8 +57,8 @@
47 57 subtest 'diurnal API accepts days param' => sub {
48 58 $t->get_ok('/api/insights/diurnal/ph?days=3')
49 59 ->status_is(200)
50 Removed: ->json_is('/days' => 3)
51 Removed: ->json_is('/probe' => 'ph');
60 Added: ->json_is( '/days' => 3 )
61 Added: ->json_is( '/probe' => 'ph' );
52 62 };
53 63
54 64 subtest 'derivatives API returns all probes' => sub {
@@ -59,42 +69,42 @@
59 69 ->json_has('/probes/do')
60 70 ->json_has('/probes/orp')
61 71 ->json_has('/probes/ec')
62 Removed: ->json_is('/hours' => 48);
72 Added: ->json_is( '/hours' => 48 );
63 73 };
64 74
65 75 subtest 'derivatives API accepts hours param' => sub {
66 76 $t->get_ok('/api/insights/derivatives?hours=24')
67 77 ->status_is(200)
68 Removed: ->json_is('/hours' => 24);
78 Added: ->json_is( '/hours' => 24 );
69 79 };
70 80
71 81 subtest 'stability API returns points array' => sub {
72 82 $t->get_ok('/api/insights/stability')
73 83 ->status_is(200)
74 84 ->json_has('/points')
75 Removed: ->json_is('/hours' => 168)
76 Removed: ->json_is('/window' => 6);
85 Added: ->json_is( '/hours' => 168 )
86 Added: ->json_is( '/window' => 6 );
77 87 };
78 88
79 89 subtest 'stability API accepts hours param' => sub {
80 90 $t->get_ok('/api/insights/stability?hours=48')
81 91 ->status_is(200)
82 Removed: ->json_is('/hours' => 48);
92 Added: ->json_is( '/hours' => 48 );
83 93 };
84 94
85 95 subtest 'correlation API returns points' => sub {
86 96 $t->get_ok('/api/correlation/ph/do?timeframe=year&range=1')
87 97 ->status_is(200)
88 98 ->json_has('/points')
89 Removed: ->json_is('/points/0/x' => '7.12')
90 Removed: ->json_is('/points/0/y' => '8.34');
99 Added: ->json_is( '/points/0/x' => '7.12' )
100 Added: ->json_is( '/points/0/y' => '8.34' );
91 101 };
92 102
93 103 subtest 'correlation API with no overlapping data' => sub {
94 104 $t->get_ok('/api/correlation/ph/ec?timeframe=year&range=1')
95 105 ->status_is(200)
96 106 ->json_has('/points')
97 Removed: ->json_is('/points' => []);
107 Added: ->json_is( '/points' => [] );
98 108 };
99 109
100 110 subtest 'correlation API with invalid probe' => sub {
@@ -110,20 +120,22 @@
110 120 };
111 121
112 122 subtest 'insights APIs with no recent data' => sub {
123 Added:
113 124 # test_app has data from 2026-07-05, outside default time windows
114 125 # so these return empty/minimal results gracefully
115 126 $t->get_ok('/api/insights/stability?hours=1')
116 127 ->status_is(200)
117 Removed: ->json_is('/points' => []);
128 Added: ->json_is( '/points' => [] );
118 129 $t->get_ok('/api/insights/derivatives?hours=1')
119 130 ->status_is(200)
120 131 ->json_has('/probes');
121 132 $t->get_ok('/api/insights/diurnal/do?days=1')
122 133 ->status_is(200)
123 Removed: ->json_is('/traces' => []);
134 Added: ->json_is( '/traces' => [] );
124 135 };
125 136
126 137 subtest 'insights APIs with matching data' => sub {
138 Added:
127 139 # Seed recent hourly rollups to exercise computation paths
128 140 my $now = time;
129 141
roles/dashboard/t/09-weather.t
index 580ff63c..8af5e094 100644..100644
@@ -141,6 +141,12 @@
141 141 ->element_exists('canvas#chart-weather-combined')
142 142 ->element_exists('canvas#chart-weather-solar')
143 143 ->element_exists('canvas#chart-weather-ground')
144 Added: ->element_exists(
145 Added: 'main section[data-controls-panel][aria-labelledby="weather-controls-title"]'
146 Added: )
147 Added: ->text_is( '#weather-controls-title', 'Weather controls' )
148 Added: ->element_exists(
149 Added: '#weather-controls .graph-control[data-default-timeframe="day"]')
144 150 ->element_exists('[data-default-timeframe="day"]');
145 151 };
146 152
roles/dashboard/templates/dashboard/graph-control.html.ep
index 209d411b..25e57d95 100644..100644
@@ -1,49 +1,44 @@
1 1 % my $dt_attr = stash('default_timeframe') ? 'data-default-timeframe="' . stash('default_timeframe') . '"' : '';
2 Removed: <div class="graph-control d-inline-flex gap-2 flex-nowrap align-items-center" <%== $dt_attr %>>
3 Removed: <div class="input-group input-group-sm range-control" aria-label="Adjust range">
4 Removed: <button
5 Removed: class="btn btn-outline-secondary"
6 Removed: type="button"
7 Removed: data-range-adjustment="decrease"
8 Removed: aria-label="Decrease range">−</button>
9 Removed: <span
10 Removed: class="input-group-text range-label"
11 Removed: data-range-label
12 Removed: aria-live="polite">1</span>
13 Removed: <button
14 Removed: class="btn btn-outline-secondary"
15 Removed: type="button"
16 Removed: data-range-adjustment="increase"
17 Removed: aria-label="Increase range">+</button>
2 Added: <div class="graph-control" <%== $dt_attr %>>
3 Added: <div class="graph-control-group graph-control-range-group">
4 Added: <span class="control-group-label" data-control-group="range">Range</span>
5 Added: <div class="input-group input-group-sm range-control" aria-label="Adjust range">
6 Added: <button
7 Added: class="btn btn-outline-secondary"
8 Added: type="button"
9 Added: data-range-adjustment="decrease"
10 Added: aria-label="Decrease range">−</button>
11 Added: <span
12 Added: class="input-group-text range-label"
13 Added: data-range-label
14 Added: aria-live="polite">1</span>
15 Added: <button
16 Added: class="btn btn-outline-secondary"
17 Added: type="button"
18 Added: data-range-adjustment="increase"
19 Added: aria-label="Increase range">+</button>
20 Added: </div>
18 21 </div>
19 Removed: <%# Full button group for md+ screens %>
20 Removed: <div class="btn-group timeframe-control d-none d-md-inline-flex" role="group" aria-label="Select timeframe">
21 Removed: % for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
22 Removed: <button
23 Removed: class="btn btn-sm btn-outline-secondary timeframe-button"
24 Removed: type="button"
25 Removed: data-timeframe="<%= $timeframe->[0] %>"
26 Removed: data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
27 Removed: % }
28 Removed: </div>
29 Removed: <%# Dropdown for small screens %>
30 Removed: <div class="dropdown d-md-none timeframe-control">
31 Removed: <button
32 Removed: class="btn btn-sm btn-outline-secondary dropdown-toggle timeframe-dropdown-toggle"
33 Removed: type="button"
34 Removed: data-bs-toggle="dropdown"
35 Removed: aria-expanded="false"
36 Removed: data-timeframe-dropdown-label>Daily</button>
37 Removed: <ul class="dropdown-menu dropdown-menu-end">
38 Removed: % for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
39 Removed: <li>
40 Removed: <button
41 Removed: class="dropdown-item timeframe-button"
42 Removed: type="button"
43 Removed: data-timeframe="<%= $timeframe->[0] %>"
44 Removed: data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
45 Removed: </li>
46 Removed: % }
47 Removed: </ul>
22 Added: <div class="graph-control-group">
23 Added: <span class="control-group-label" data-control-group="timeframe">Timeframe</span>
24 Added: <div class="dropdown timeframe-control">
25 Added: <button
26 Added: class="btn btn-sm btn-outline-secondary dropdown-toggle timeframe-dropdown-toggle"
27 Added: type="button"
28 Added: data-bs-toggle="dropdown"
29 Added: aria-expanded="false"
30 Added: data-timeframe-dropdown-label>Daily</button>
31 Added: <ul class="dropdown-menu dropdown-menu-end">
32 Added: % for my $timeframe ( [ hour => 'Hourly' ], [ day => 'Daily' ], [ week => 'Weekly' ], [ month => 'Monthly' ], [ year => 'Yearly' ] ) {
33 Added: <li>
34 Added: <button
35 Added: class="dropdown-item timeframe-button"
36 Added: type="button"
37 Added: data-timeframe="<%= $timeframe->[0] %>"
38 Added: data-timeframe-label="<%= $timeframe->[1] %>"><%= $timeframe->[1] %></button>
39 Added: </li>
40 Added: % }
41 Added: </ul>
42 Added: </div>
48 43 </div>
49 44 </div>
roles/dashboard/templates/dashboard/graph.html.ep
index 253876a3..a43d1839 100644..100644
@@ -2,11 +2,14 @@
2 2 % title "FAPG DAQ $probe->{label}";
3 3 % stash use_charts => 1;
4 4
5 Removed: <section id="probe-detail" class="mb-4" aria-label="<%= $probe->{label} %> probe detail">
6 Removed: <div id="probe-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5 Added: <div class="dashboard-page-layout mb-4">
6 Added: <section id="probe-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="probe-controls-title">
7 Added: <h2 id="probe-controls-title" class="controls-panel-title"><%= $probe->{label} %> chart controls</h2>
7 8 %= include 'dashboard/graph-control', aria_label => "$probe->{label} chart timeframe", default_timeframe => 'day'
8 Removed: </div>
9 Added: </section>
9 10
11 Added: <section id="probe-detail" class="dashboard-page-content" aria-label="<%= $probe->{label} %> probe detail">
12 Added:
10 13 %= include 'dashboard/probe-card', probe => $probe
11 14
12 15 <div id="latest-readings" class="card mt-3" aria-label="Last ten reading messages for <%= $probe->{label} %>">
@@ -35,4 +38,5 @@
35 38 </div>
36 39 </div>
37 40
38 Removed: </section>
41 Added: </section>
42 Added: </div>
roles/dashboard/templates/dashboard/index.html.ep
index 71e35efb..a3943b1e 100644..100644
@@ -2,7 +2,28 @@
2 2 % title 'FAPG DAQ Dashboard';
3 3 % stash use_charts => 1;
4 4
5 Added: <header id="mobile-home-masthead" class="mobile-home-masthead d-md-none" data-home-masthead>
6 Added: <div class="mobile-home-masthead-row">
7 Added: <p class="mobile-home-brand mb-0">FAPG</p>
8 Added: <a class="mobile-home-phone" href="tel:+33787044193" aria-label="Call FAPG at 07 87 04 41 93" data-home-phone>
9 Added: <svg aria-hidden="true" viewBox="0 0 24 24" focusable="false">
10 Added: <path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.12.9.33 1.78.62 2.64a2 2 0 0 1-.45 2.11L8.01 9.74a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.86.29 1.74.5 2.64.62A2 2 0 0 1 22 16.92z" />
11 Added: </svg>
12 Added: </a>
13 Added: </div>
14 Added: </header>
15 Added:
5 16 <section id="index-overview" class="mb-3" aria-label="DAQ status overview">
17 Added: <figure class="card home-koan-card mb-3" aria-label="Aquaponic Zen koan" data-home-koan-card>
18 Added: <div class="card-body">
19 Added: <blockquote class="mb-1">
20 Added: <p class="home-koan mb-0" data-home-koan>
21 Added: <em class="home-koan-text"><%= $home_koan %></em>
22 Added: </p>
23 Added: </blockquote>
24 Added: <figcaption class="home-koan-attribution" data-home-koan-attribution>&mdash; <%= $home_koan_master %></figcaption>
25 Added: </div>
26 Added: </figure>
6 27
7 28 <section id="quick-readings" class="reading-quick-status mb-3" aria-label="Sensor reading quick status">
8 29 <div class="row g-2" data-reading-quick-list>
roles/dashboard/templates/dashboard/insights.html.ep
index 7b48e0dd..0b6a0323 100644..100644
@@ -2,11 +2,14 @@
2 2 % title 'FAPG DAQ Insights';
3 3 % stash use_charts => 1;
4 4
5 Removed: <section id="insights-page" class="mb-4" aria-label="Insights visualisations">
6 Removed: <div id="insights-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5 Added: <div class="dashboard-page-layout mb-4">
6 Added: <section id="insights-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="insights-controls-title">
7 Added: <h2 id="insights-controls-title" class="controls-panel-title">Insights controls</h2>
7 8 %= include 'dashboard/graph-control', aria_label => 'Insights timeframe', default_timeframe => 'day'
8 Removed: </div>
9 Added: </section>
9 10
11 Added: <section id="insights-page" class="dashboard-page-content" aria-label="Insights visualisations">
12 Added:
10 13 <div class="row g-3">
11 14 <div class="col-12 col-xl-6" id="stability">
12 15 <div class="card">
@@ -63,4 +66,5 @@
63 66 </div>
64 67 </div>
65 68 </div>
66 Removed: </section>
69 Added: </section>
70 Added: </div>
roles/dashboard/templates/dashboard/probes.html.ep
index 4e94700f..3e6b96af 100644..100644
@@ -2,16 +2,19 @@
2 2 % title 'FAPG DAQ Probes';
3 3 % stash use_charts => 1;
4 4
5 Removed: <section id="probes-page" class="mb-4" aria-label="Probe readings">
6 Removed: <div id="probes-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5 Added: <div class="dashboard-page-layout mb-4">
6 Added: <section id="probes-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="probes-controls-title">
7 Added: <h2 id="probes-controls-title" class="controls-panel-title">Probe chart controls</h2>
7 8 %= include 'dashboard/graph-control', aria_label => 'All probe chart timeframes', default_timeframe => 'day'
8 Removed: </div>
9 Added: </section>
9 10
10 Removed: <div class="row g-3 probes-grid">
11 Removed: % for my $probe ($probes->@*) {
12 Removed: <div class="col-12 col-xl-6">
13 Removed: %= include 'dashboard/probe-card', probe => $probe
11 Added: <section id="probes-page" class="dashboard-page-content" aria-label="Probe readings">
12 Added: <div class="row g-3 probes-grid">
13 Added: % for my $probe ($probes->@*) {
14 Added: <div class="col-12 col-xl-6">
15 Added: %= include 'dashboard/probe-card', probe => $probe
16 Added: </div>
17 Added: % }
14 18 </div>
15 Removed: % }
16 Removed: </div>
17 Removed: </section>
19 Added: </section>
20 Added: </div>
roles/dashboard/templates/dashboard/weather.html.ep
index 39cdece6..ec28ab46 100644..100644
@@ -2,11 +2,14 @@
2 2 % title 'FAPG DAQ Weather';
3 3 % stash use_charts => 1;
4 4
5 Removed: <section id="weather-page" class="mb-4" aria-label="Weather data">
6 Removed: <div id="weather-controls" class="d-flex align-items-center justify-content-end gap-2 mb-3 sticky-controls">
5 Added: <div class="dashboard-page-layout mb-4">
6 Added: <section id="weather-controls" class="dashboard-controls-panel sticky-controls" data-controls-panel aria-labelledby="weather-controls-title">
7 Added: <h2 id="weather-controls-title" class="controls-panel-title">Weather controls</h2>
7 8 %= include 'dashboard/graph-control', aria_label => 'Weather timeframe', default_timeframe => 'day'
8 Removed: </div>
9 Added: </section>
9 10
11 Added: <section id="weather-page" class="dashboard-page-content" aria-label="Weather data">
12 Added:
10 13 <div class="row g-3">
11 14 <div class="col-12 col-xl-6" id="weather-combined">
12 15 <div class="card">
@@ -41,4 +44,5 @@
41 44 </div>
42 45 </div>
43 46 </div>
44 Removed: </section>
47 Added: </section>
48 Added: </div>
roles/dashboard/templates/layouts/default.html.ep
index 32bc503c..7b57f8b2 100644..100644
@@ -18,68 +18,42 @@
18 18
19 19 <a class="visually-hidden-focusable" href="#main-content">Skip to main content</a>
20 20
21 Removed: <nav id="navbar" class="navbar navbar-expand-md bg-body border-bottom shadow-sm sticky-top mb-3" aria-label="Dashboard navigation">
21 Added: % my $nav_page = stash('nav_page') // '';
22 Added: <nav id="navbar" class="navbar navbar-expand-md d-none d-md-flex bg-body border-bottom shadow-sm sticky-top mb-3" aria-label="Dashboard navigation">
22 23 <div class="container-fluid px-3">
23 Removed:
24 24 <a id="navbar-brand" class="navbar-brand fw-bold text-success" href="/">FAPG</a>
25 25
26 Removed: % my $page_title = stash('page_title') // '';
27 Removed: % if ($page_title) {
28 Removed: <span id="navbar-title" class="navbar-text fw-semibold text-body" aria-hidden="true"><%= $page_title %></span>
29 Removed: % }
30 Removed:
31 Removed: <div class="d-flex align-items-center gap-2 ms-auto order-md-last">
32 Removed: <button
33 Removed: class="navbar-toggler"
34 Removed: type="button"
35 Removed: data-bs-toggle="collapse"
36 Removed: data-bs-target="#navbarLinks"
37 Removed: aria-controls="navbarLinks"
38 Removed: aria-expanded="false"
39 Removed: aria-label="Toggle navigation">
40 Removed: <span class="navbar-toggler-icon"></span>
41 Removed: </button>
42 Removed: </div>
43 Removed:
44 Removed: <div class="collapse navbar-collapse" id="navbarLinks">
45 Removed: <ul class="navbar-nav ms-auto mb-2 mb-md-0">
46 Removed: <li class="nav-item dropdown">
47 Removed: <a class="nav-link fw-semibold dropdown-toggle<%= (stash('nav_page') // '') eq 'probes' ? ' active' : '' %>"
48 Removed: href="#"
49 Removed: role="button"
50 Removed: data-bs-toggle="dropdown"
51 Removed: aria-expanded="false"
52 Removed: <%= (stash('nav_page') // '') eq 'probes' ? 'aria-current="page"' : '' %>>Probes</a>
53 Removed: <ul class="dropdown-menu">
54 Removed: <li><a class="dropdown-item" href="/probes">Readings</a></li>
55 Removed: <li><a class="dropdown-item" href="/probes/status">Status</a></li>
56 Removed: </ul>
57 Removed: </li>
58 Removed: <li class="nav-item">
59 Removed: % if ((stash('nav_page') // '') eq 'insights') {
60 Removed: <a class="nav-link fw-semibold active" href="/insights" aria-current="page">Insights</a>
61 Removed: % } else {
62 Removed: <a class="nav-link fw-semibold" href="/insights">Insights</a>
63 Removed: % }
64 Removed: </li>
65 Removed: <li class="nav-item">
66 Removed: % if ((stash('nav_page') // '') eq 'weather') {
67 Removed: <a class="nav-link fw-semibold active" href="/weather" aria-current="page">Weather</a>
68 Removed: % } else {
69 Removed: <a class="nav-link fw-semibold" href="/weather">Weather</a>
70 Removed: % }
71 Removed: </li>
72 Removed: <li class="nav-item">
73 Removed: % if ((stash('nav_page') // '') eq 'downloads') {
74 Removed: <a class="nav-link fw-semibold active" href="/downloads" aria-current="page">Downloads</a>
75 Removed: % } else {
76 Removed: <a class="nav-link fw-semibold" href="/downloads">Downloads</a>
77 Removed: % }
78 Removed: </li>
79 Removed: </ul>
80 Removed: </div>
81 Removed:
26 Added: <ul class="navbar-nav ms-auto">
27 Added: <li class="nav-item dropdown">
28 Added: <a class="nav-link fw-semibold dropdown-toggle<%== $nav_page eq 'probes' ? ' active' : '' %>"
29 Added: href="#"
30 Added: role="button"
31 Added: data-bs-toggle="dropdown"
32 Added: aria-expanded="false"
33 Added: <%== $nav_page eq 'probes' ? 'aria-current="page"' : '' %>>Probes</a>
34 Added: <ul class="dropdown-menu">
35 Added: <li><a class="dropdown-item" href="/probes">Readings</a></li>
36 Added: <li><a class="dropdown-item" href="/probes/status">Status</a></li>
37 Added: </ul>
38 Added: </li>
39 Added: <li class="nav-item">
40 Added: <a class="nav-link fw-semibold<%== $nav_page eq 'insights' ? ' active' : '' %>" href="/insights"<%== $nav_page eq 'insights' ? ' aria-current="page"' : '' %>>Insights</a>
41 Added: </li>
42 Added: <li class="nav-item">
43 Added: <a class="nav-link fw-semibold<%== $nav_page eq 'weather' ? ' active' : '' %>" href="/weather"<%== $nav_page eq 'weather' ? ' aria-current="page"' : '' %>>Weather</a>
44 Added: </li>
45 Added: <li class="nav-item">
46 Added: <a class="nav-link fw-semibold<%== $nav_page eq 'downloads' ? ' active' : '' %>" href="/downloads"<%== $nav_page eq 'downloads' ? ' aria-current="page"' : '' %>>Downloads</a>
47 Added: </li>
48 Added: </ul>
82 49 </div>
50 Added: </nav>
51 Added:
52 Added: <nav class="mobile-bottom-nav d-md-none" aria-label="Primary navigation" data-mobile-navigation>
53 Added: <a class="mobile-bottom-nav-link<%== $nav_page eq 'home' ? ' active' : '' %>" href="/"<%== $nav_page eq 'home' ? ' aria-current="page"' : '' %>>Home</a>
54 Added: <a class="mobile-bottom-nav-link<%== $nav_page eq 'insights' ? ' active' : '' %>" href="/insights"<%== $nav_page eq 'insights' ? ' aria-current="page"' : '' %>>Insights</a>
55 Added: <a class="mobile-bottom-nav-link<%== $nav_page eq 'probes' ? ' active' : '' %>" href="/probes"<%== $nav_page eq 'probes' ? ' aria-current="page"' : '' %>>Probes</a>
56 Added: <a class="mobile-bottom-nav-link<%== $nav_page eq 'weather' ? ' active' : '' %>" href="/weather"<%== $nav_page eq 'weather' ? ' aria-current="page"' : '' %>>Weather</a>
83 57 </nav>
84 58
85 59 <main id="main-content" class="container">