Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: design audit

authored by

Patrick Dewey and committed by
Tangled
850bae69 74bbd0bd

+145 -109
+1 -1
go.mod
··· 26 26 golang.org/x/sync v0.20.0 27 27 gorm.io/gorm v1.31.1 28 28 modernc.org/sqlite v1.48.1 29 - tangled.org/pdewey.com/atp v0.0.0-20260412024836-2795bc15b775 29 + tangled.org/pdewey.com/atp v0.0.0-20260412220944-ab8db352c13e 30 30 ) 31 31 32 32 require (
+2
go.sum
··· 1067 1067 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= 1068 1068 tangled.org/pdewey.com/atp v0.0.0-20260412024836-2795bc15b775 h1:0+cKQFnOepiZrkZnGP8F45cCBf83/9FLXMX4FvqcUqU= 1069 1069 tangled.org/pdewey.com/atp v0.0.0-20260412024836-2795bc15b775/go.mod h1:Vsfo53wETUM7mlnZqhr9HrGB6/yoqhbpXjwWqp+ZjOA= 1070 + tangled.org/pdewey.com/atp v0.0.0-20260412220944-ab8db352c13e h1:ISWAtPN74Y7EN7sl6yruNlKqF8ifZcOAaCPzGiCHU54= 1071 + tangled.org/pdewey.com/atp v0.0.0-20260412220944-ab8db352c13e/go.mod h1:Vsfo53wETUM7mlnZqhr9HrGB6/yoqhbpXjwWqp+ZjOA=
+26 -16
internal/web/components/action_bar.templ
··· 82 82 class="action-btn" 83 83 title="View comments" 84 84 > 85 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 85 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 86 86 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 87 87 </svg> 88 88 <span>{ fmt.Sprintf("%d", props.CommentCount) }</span> ··· 91 91 <!-- Hidden indicator (visible to moderators) --> 92 92 if props.IsModerator && props.IsRecordHidden { 93 93 <span class="hidden-badge" title="This record is hidden from the public feed"> 94 - <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> 94 + <svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"> 95 95 <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path> 96 96 </svg> 97 97 Hidden ··· 120 120 class="action-btn" 121 121 aria-label="More options" 122 122 > 123 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 123 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 124 124 <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path> 125 125 </svg> 126 126 </button> ··· 134 134 x-transition:leave-start="transform opacity-100 scale-100" 135 135 x-transition:leave-end="transform opacity-0 scale-95" 136 136 class="action-menu" 137 + role="menu" 137 138 :class="openUp ? 'bottom-full mb-1' : 'top-full mt-1'" 138 139 x-cloak 139 140 > 140 141 if props.IsOwner { 141 142 if props.EditURL != "" { 142 - <a href={ templ.SafeURL(props.EditURL) } class="action-menu-item"> 143 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 143 + <a href={ templ.SafeURL(props.EditURL) } class="action-menu-item" role="menuitem"> 144 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 144 145 <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path> 145 146 </svg> 146 147 Edit ··· 164 165 type="button" 165 166 @click={ fmt.Sprintf("navigator.clipboard.writeText('%s'); moreOpen = false; $dispatch('notify', {message: 'AT URI copied'})", props.SubjectURI) } 166 167 class="action-menu-item" 168 + role="menuitem" 167 169 > 168 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 170 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 169 171 <path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5"></path> 170 172 </svg> 171 173 Copy AT URI ··· 182 184 hx-swap="none" 183 185 @click="moreOpen = false; $dispatch('notify', {message: 'Record unhidden'})" 184 186 class="action-menu-item" 187 + role="menuitem" 185 188 > 186 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 189 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 187 190 <path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"></path> 188 191 <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"></path> 189 192 </svg> ··· 198 201 hx-confirm="Hide this record from the public feed?" 199 202 @click="moreOpen = false; $dispatch('notify', {message: 'Record hidden from feed'})" 200 203 class="action-menu-item action-menu-item-warning" 204 + role="menuitem" 201 205 > 202 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 206 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 203 207 <path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"></path> 204 208 </svg> 205 209 Hide from feed ··· 218 222 hx-confirm="Block this user? All their content will be hidden from the feed." 219 223 @click="moreOpen = false; $dispatch('notify', {message: 'User blocked'})" 220 224 class="action-menu-item action-menu-item-danger" 225 + role="menuitem" 221 226 > 222 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 227 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 223 228 <path stroke-linecap="round" stroke-linejoin="round" d="M18.364 18.364A9 9 0 0 0 5.636 5.636m12.728 12.728A9 9 0 0 1 5.636 5.636m12.728 12.728L5.636 5.636"></path> 224 229 </svg> 225 230 Block user ··· 234 239 type="button" 235 240 @click={ fmt.Sprintf("moreOpen = false; document.getElementById('report-modal-%s').showModal()", escapeForAlpine(props.SubjectURI)) } 236 241 class="action-menu-item" 242 + role="menuitem" 237 243 > 238 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 244 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 239 245 <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 240 246 </svg> 241 247 Report ··· 263 269 class="action-btn" 264 270 aria-label="Share" 265 271 > 266 - <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 272 + <svg x-show="!copied" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 267 273 <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path> 268 274 </svg> 269 - <svg x-show="copied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 275 + <svg x-show="copied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 270 276 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"></path> 271 277 </svg> 272 278 <span x-show="copied" x-cloak>Copied!</span> ··· 330 336 rows="4" 331 337 maxlength="500" 332 338 class="w-full form-textarea" 339 + aria-label="Report reason" 333 340 ></textarea> 334 341 <div class="flex justify-between text-xs text-brown-500 mt-1"> 335 342 <span>Optional, but helpful for moderators</span> ··· 362 369 <template x-if="success"> 363 370 <div class="text-center py-4"> 364 371 <div class="text-green-600 mb-2"> 365 - <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 372 + <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 366 373 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 367 374 </svg> 368 375 </div> ··· 393 400 hx-swap="none" 394 401 hx-on--after-request={ deleteRedirectScript(props.DeleteRedirect) } 395 402 class="action-menu-item action-menu-item-danger" 403 + role="menuitem" 396 404 > 397 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 405 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 398 406 <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 399 407 </svg> 400 408 Delete ··· 410 418 hx-target={ props.getDeleteTarget() } 411 419 hx-swap="outerHTML" 412 420 class="action-menu-item action-menu-item-danger" 421 + role="menuitem" 413 422 > 414 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 423 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 415 424 <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path> 416 425 </svg> 417 426 Delete ··· 426 435 hx-target="#modal-container" 427 436 hx-swap="innerHTML" 428 437 class="action-menu-item" 438 + role="menuitem" 429 439 @click="moreOpen = false" 430 440 hx-on--after-swap={ editModalAfterSwapScript() } 431 441 > 432 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 442 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 433 443 <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125"></path> 434 444 </svg> 435 445 Edit
+5 -5
internal/web/components/brew_list_table.templ
··· 47 47 </div> 48 48 <div class="flex items-center gap-1"> 49 49 if isOwnProfile { 50 - <a href={ templ.SafeURL("/brews/" + brew.RKey) } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200">View</a> 51 - <a href={ templ.SafeURL("/brews/" + brew.RKey + "/edit") } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200">Edit</a> 50 + <a href={ templ.SafeURL("/brews/" + brew.RKey) } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200">View</a> 51 + <a href={ templ.SafeURL("/brews/" + brew.RKey + "/edit") } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200">Edit</a> 52 52 <button 53 53 hx-delete={ "/brews/" + brew.RKey } 54 54 hx-confirm="Are you sure you want to delete this brew?" 55 55 hx-target="closest .feed-card" 56 56 hx-swap="outerHTML swap:0.3s" 57 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 57 + class="text-brown-500 hover:text-brown-800 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200" 58 58 >Delete</button> 59 59 } else if profileHandle != "" { 60 - <a href={ templ.SafeURL(fmt.Sprintf("/brews/%s?owner=%s", brew.RKey, profileHandle)) } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200">View</a> 60 + <a href={ templ.SafeURL(fmt.Sprintf("/brews/%s?owner=%s", brew.RKey, profileHandle)) } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200">View</a> 61 61 } else { 62 - <a href={ templ.SafeURL("/brews/" + brew.RKey) } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200">View</a> 62 + <a href={ templ.SafeURL("/brews/" + brew.RKey) } class="text-brown-600 hover:text-brown-900 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200">View</a> 63 63 } 64 64 </div> 65 65 </div>
+14 -2
internal/web/components/combo_select.templ
··· 73 73 :placeholder="placeholder" 74 74 class="w-full form-input-lg" 75 75 autocomplete="off" 76 + role="combobox" 77 + aria-autocomplete="list" 78 + :aria-expanded="isOpen && (allItems.length > 0 || query.trim()) ? 'true' : 'false'" 79 + aria-controls="combo-dropdown" 80 + aria-label="Search and select" 76 81 /> 77 82 <button 78 83 type="button" ··· 80 85 @click="clear()" 81 86 class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600" 82 87 x-cloak 88 + aria-label="Clear selection" 83 89 > 84 90 @IconX() 85 91 </button> 86 92 </div> 87 - <div x-show="isOpen && (allItems.length > 0 || query.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> 93 + <div id="combo-dropdown" role="listbox" x-show="isOpen && (allItems.length > 0 || query.trim())" x-cloak class="combo-dropdown" @mousedown.prevent> 88 94 <div x-show="isCreating" x-cloak class="combo-creating">Creating...</div> 89 95 <template x-if="!isCreating"> 90 96 <div> ··· 94 100 <template x-for="(entity, i) in userResults" :key="entity.rkey || entity.RKey"> 95 101 <div 96 102 class="combo-item" 103 + role="option" 97 104 :data-highlighted="highlightIndex === i" 98 105 @click="selectEntity(entity)" 99 106 @mouseenter="highlightIndex = i" ··· 109 116 <template x-for="(entity, ci) in closedResults" :key="entity.rkey || entity.RKey"> 110 117 <div 111 118 class="combo-item opacity-60" 119 + role="option" 112 120 :data-highlighted="highlightIndex === userResults.length + ci" 113 121 @click="selectEntity(entity)" 114 122 @mouseenter="highlightIndex = userResults.length + ci" ··· 124 132 <template x-for="(s, j) in communityResults" :key="s.source_uri || j"> 125 133 <div 126 134 class="combo-item" 135 + role="option" 127 136 :data-highlighted="highlightIndex === userResults.length + closedResults.length + j" 128 137 @click="selectSuggestion(s)" 129 138 @mouseenter="highlightIndex = userResults.length + closedResults.length + j" ··· 143 152 <template x-if="query.trim() && !exactMatch"> 144 153 <div 145 154 class="combo-item-create" 155 + role="option" 146 156 :data-highlighted="highlightIndex === userResults.length + closedResults.length + communityResults.length" 147 157 @click="createNew()" 148 158 @mouseenter="highlightIndex = userResults.length + closedResults.length + communityResults.length" ··· 156 166 </div> 157 167 </template> 158 168 </div> 169 + <div class="sr-only" aria-live="polite" x-text="allItems.length + ' results available'"></div> 159 170 <!-- Inline create form with extra details --> 160 171 <div x-show="showCreateForm" x-transition x-cloak class="mt-2 p-3 rounded-lg" style="background: var(--surface-bg); border: 1px solid var(--surface-border);"> 161 172 <p class="text-sm font-medium text-brown-900 mb-2"> ··· 207 218 @click="clearRoaster()" 208 219 class="absolute right-2 top-1/2 -translate-y-1/2 text-brown-400 hover:text-brown-600" 209 220 x-cloak 221 + aria-label="Clear roaster" 210 222 > 211 223 @IconX() 212 224 </button> ··· 254 266 </div> 255 267 <!-- New roaster detail fields --> 256 268 <template x-if="creatingNewRoaster"> 257 - <div class="mt-2 ml-3 space-y-2 border-l-2 pl-3" style="border-color: var(--surface-border);"> 269 + <div class="mt-2 ml-3 space-y-2 pl-3 rounded-lg py-2 pr-2" style="background: var(--surface-bg);"> 258 270 <p class="text-xs text-brown-500"> 259 271 New roaster: <span x-text="newRoasterName" class="font-medium"></span> 260 272 </p>
+9 -7
internal/web/components/comments.templ
··· 27 27 class="comment-btn" 28 28 aria-label="Comments" 29 29 > 30 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 30 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> 31 31 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 32 32 </svg> 33 33 if props.CommentCount > 0 { ··· 60 60 <!-- Section header --> 61 61 <div class="comment-section-header"> 62 62 <div class="flex items-center gap-2"> 63 - <svg class="w-5 h-5 text-brown-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 63 + <svg class="w-5 h-5 text-brown-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 64 64 <path stroke-linecap="round" stroke-linejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"></path> 65 65 </svg> 66 - <h3 class="text-lg font-semibold text-brown-900"> 66 + <h2 class="text-lg font-semibold text-brown-900"> 67 67 Discussion 68 - </h3> 68 + </h2> 69 69 if len(props.Comments) > 0 { 70 70 <span class="comment-count-badge">{ fmt.Sprintf("%d", len(props.Comments)) }</span> 71 71 } ··· 79 79 }) 80 80 } else { 81 81 <div class="comment-login-prompt"> 82 - <svg class="w-5 h-5 text-brown-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 82 + <svg class="w-5 h-5 text-brown-500 flex-shrink-0" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 83 83 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 84 84 </svg> 85 85 <p class="text-sm text-brown-600"> ··· 123 123 rows="2" 124 124 maxlength="1000" 125 125 required 126 + aria-label="Write a comment" 126 127 ></textarea> 127 128 <div class="flex justify-between items-center"> 128 129 <span class="text-xs text-brown-400 tracking-wide">1000 char limit</span> ··· 149 150 <div class="comment-list"> 150 151 if len(props.Comments) == 0 { 151 152 <div class="comment-empty-state"> 152 - <svg class="w-10 h-10 text-brown-300 mx-auto mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24"> 153 + <svg class="w-10 h-10 text-brown-300 mx-auto mb-3" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24" aria-hidden="true"> 153 154 <path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"></path> 154 155 </svg> 155 156 <p class="text-brown-500 text-sm font-medium">No comments yet</p> ··· 214 215 class="comment-reply-btn" 215 216 aria-label="Reply to comment" 216 217 > 217 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 218 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 218 219 <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3"></path> 219 220 </svg> 220 221 Reply ··· 285 286 rows="2" 286 287 maxlength="1000" 287 288 required 289 + aria-label="Write a reply" 288 290 ></textarea> 289 291 <div class="flex justify-end gap-2"> 290 292 <button type="button" @click="showReplyForm = false" class="btn-secondary text-xs py-1 px-3">
+2 -2
internal/web/components/entity_tables.templ
··· 32 32 hx-get={ modalPath } 33 33 hx-target="#modal-container" 34 34 hx-swap="innerHTML" 35 - class="text-brown-600 hover:text-brown-900 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 35 + class="text-brown-600 hover:text-brown-900 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200" 36 36 >Edit</button> 37 37 <button 38 38 hx-delete={ deletePath } 39 39 hx-confirm={ "Are you sure you want to delete this " + entityName + "?" } 40 40 hx-target="closest .feed-card" 41 41 hx-swap="outerHTML swap:0.3s" 42 - class="text-brown-500 hover:text-brown-800 text-sm font-medium px-1.5 py-0.5 rounded hover:bg-brown-200" 42 + class="text-brown-500 hover:text-brown-800 text-sm font-medium px-2.5 py-1.5 rounded hover:bg-brown-200" 43 43 >Delete</button> 44 44 </div> 45 45 }
+19 -13
internal/web/components/header.templ
··· 46 46 } 47 47 if props.IsAuthenticated { 48 48 <!-- Notification bell --> 49 - <a href="/notifications" class="relative hover:opacity-80 transition p-1" title="Notifications"> 49 + <a href="/notifications" class="relative hover:opacity-80 transition p-2" title="Notifications" aria-label="Notifications"> 50 50 <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 51 51 <path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"></path> 52 52 </svg> ··· 58 58 </a> 59 59 <!-- Create new dropdown --> 60 60 <div x-data="{ open: false }" class="relative"> 61 - <button @click="open = !open" @click.outside="open = false" class="hover:opacity-80 transition p-1 focus:outline-none" title="Create new"> 61 + <button @click="open = !open" @click.outside="open = false" class="hover:opacity-80 transition p-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded" title="Create new" aria-label="Create new" :aria-expanded="open"> 62 62 <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 63 63 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path> 64 64 </svg> 65 65 </button> 66 - <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu w-52"> 66 + <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu w-52" role="menu"> 67 67 <div class="dropdown-header"> 68 68 <p class="text-xs font-semibold uppercase tracking-wider text-brown-500">Log</p> 69 69 </div> 70 - <a href="/brews/new" class="dropdown-item flex items-center gap-2" @click="open = false"> 70 + <a href="/brews/new" class="dropdown-item flex items-center gap-2" @click="open = false" role="menuitem"> 71 71 @IconCoffee() 72 72 New Brew 73 73 </a> ··· 89 89 hx-target="#modal-container" 90 90 hx-swap="innerHTML" 91 91 @click="open = false" 92 + role="menuitem" 92 93 > 93 94 @IconLeaf() 94 95 Bean ··· 99 100 hx-target="#modal-container" 100 101 hx-swap="innerHTML" 101 102 @click="open = false" 103 + role="menuitem" 102 104 > 103 105 @IconStore() 104 106 Roaster ··· 118 120 hx-target="#modal-container" 119 121 hx-swap="innerHTML" 120 122 @click="open = false" 123 + role="menuitem" 121 124 > 122 125 @IconDisc() 123 126 Grinder ··· 128 131 hx-target="#modal-container" 129 132 hx-swap="innerHTML" 130 133 @click="open = false" 134 + role="menuitem" 131 135 > 132 136 @IconBrewer() 133 137 Brewer ··· 138 142 hx-target="#modal-container" 139 143 hx-swap="innerHTML" 140 144 @click="open = false" 145 + role="menuitem" 141 146 > 142 147 @IconFileText() 143 148 Recipe ··· 146 151 </div> 147 152 <!-- User profile dropdown --> 148 153 <div x-data="{ open: false }" class="relative"> 149 - <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"> 154 + <button @click="open = !open" @click.outside="open = false" class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50 rounded" aria-label="User menu" :aria-expanded="open"> 150 155 @Avatar(AvatarProps{ 151 156 AvatarURL: getHeaderAvatarURL(props.UserProfile), 152 157 DisplayName: getHeaderDisplayName(props.UserProfile), ··· 157 162 </svg> 158 163 </button> 159 164 <!-- Dropdown menu --> 160 - <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu"> 165 + <div x-show="open" x-cloak x-transition:enter="transition ease-out duration-100" x-transition:enter-start="transform opacity-0 scale-95" x-transition:enter-end="transform opacity-100 scale-100" x-transition:leave="transition ease-in duration-75" x-transition:leave-start="transform opacity-100 scale-100" x-transition:leave-end="transform opacity-0 scale-95" class="dropdown-menu" role="menu"> 161 166 if props.UserProfile != nil && props.UserProfile.Handle != "" { 162 167 <div class="dropdown-header"> 163 168 <p class="text-sm font-medium text-brown-900 truncate"> ··· 170 175 <p class="text-xs text-brown-500 truncate">{ "@" + props.UserProfile.Handle }</p> 171 176 </div> 172 177 } 173 - <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item"> 178 + <a href={ templ.SafeURL("/profile/" + getProfileIdentifier(props.UserProfile, props.UserDID)) } class="dropdown-item" role="menuitem"> 174 179 View Profile 175 180 </a> 176 - <a href="/my-coffee" class="dropdown-item"> 181 + <a href="/my-coffee" class="dropdown-item" role="menuitem"> 177 182 My Coffee 178 183 </a> 179 - <a href="/recipes" class="dropdown-item"> 184 + <a href="/recipes" class="dropdown-item" role="menuitem"> 180 185 Recipes 181 186 </a> 182 - <a href="/settings" class="dropdown-item"> 187 + <a href="/settings" class="dropdown-item" role="menuitem"> 183 188 Settings 184 189 </a> 185 190 if props.IsModerator { 186 191 <div class="dropdown-divider"></div> 187 - <a href="/_mod" class="dropdown-item dropdown-item-mod"> 192 + <a href="/_mod" class="dropdown-item dropdown-item-mod" role="menuitem"> 188 193 <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 189 194 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z"></path> 190 195 </svg> ··· 193 198 } 194 199 <div class="dropdown-divider"> 195 200 <form action="/logout" method="POST" @submit="if(window.ArabicaCache)window.ArabicaCache.invalidateCache()"> 196 - <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors"> 201 + <button type="submit" class="w-full text-left px-4 py-2 text-sm text-brown-700 hover:bg-brown-50 transition-colors" role="menuitem"> 197 202 Logout 198 203 </button> 199 204 </form> ··· 215 220 <dialog id="login-modal" class="modal-dialog" x-data @open-login.window="$el.showModal()"> 216 221 <div class="modal-content"> 217 222 <div class="flex items-center justify-between mb-4"> 218 - <h3 class="modal-title mb-0">Log in with your Atmosphere account</h3> 223 + <h2 class="modal-title mb-0">Log in with your Atmosphere account</h2> 219 224 <button 220 225 type="button" 221 226 @click="$el.closest('dialog').close()" 222 227 class="text-brown-400 hover:text-brown-600 transition-colors" 228 + aria-label="Close" 223 229 > 224 230 @IconX() 225 231 </button>
+2 -2
internal/web/components/popular_recipes.templ
··· 37 37 <!-- Author --> 38 38 <div class="flex items-center gap-2 mb-2"> 39 39 if recipe.AuthorAvatar != "" { 40 - <img src={ recipe.AuthorAvatar } class="w-6 h-6 rounded-full object-cover" alt=""/> 40 + <img src={ recipe.AuthorAvatar } class="w-6 h-6 rounded-full object-cover" loading="lazy" alt="" width="24" height="24"/> 41 41 } else { 42 42 <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold"> 43 43 { authorInitial(recipe) } ··· 89 89 if len(recipe.ForkerAvatars) > 0 { 90 90 <div class="flex -space-x-1.5 ml-auto"> 91 91 for _, avatar := range recipe.ForkerAvatars[:min(3, len(recipe.ForkerAvatars))] { 92 - <img src={ avatar } class="w-5 h-5 rounded-full object-cover border border-white"/> 92 + <img src={ avatar } class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" alt="" width="20" height="20"/> 93 93 } 94 94 </div> 95 95 }
+15
internal/web/components/shared.templ
··· 383 383 src={ bff.SafeAvatarURL(props.AvatarURL) } 384 384 alt="" 385 385 class={ avatarClass(props.Size) } 386 + loading="lazy" 387 + width={ avatarDimension(props.Size) } 388 + height={ avatarDimension(props.Size) } 386 389 /> 387 390 } else { 388 391 <div class={ avatarPlaceholderClass(props.Size) }> ··· 406 409 return "avatar-lg" 407 410 default: 408 411 return "avatar-md" 412 + } 413 + } 414 + 415 + // avatarDimension returns the pixel dimension string for avatar images based on size 416 + func avatarDimension(size string) string { 417 + switch size { 418 + case "sm": 419 + return "32" 420 + case "lg": 421 + return "80" 422 + default: 423 + return "48" 409 424 } 410 425 } 411 426
+6 -6
internal/web/pages/manage.templ
··· 29 29 // ManageTabs renders the tab navigation 30 30 templ ManageTabs() { 31 31 <div class="mb-6 border-b-2 border-brown-300"> 32 - <nav class="-mb-px flex space-x-8"> 32 + <nav class="-mb-px flex space-x-4 sm:space-x-8 overflow-x-auto"> 33 33 <button 34 34 @click="tab = 'beans'" 35 35 :class="tab === 'beans' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 36 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 36 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 37 37 > 38 38 Beans 39 39 </button> 40 40 <button 41 41 @click="tab = 'roasters'" 42 42 :class="tab === 'roasters' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 43 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 43 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 44 44 > 45 45 Roasters 46 46 </button> 47 47 <button 48 48 @click="tab = 'grinders'" 49 49 :class="tab === 'grinders' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 50 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 50 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 51 51 > 52 52 Grinders 53 53 </button> 54 54 <button 55 55 @click="tab = 'brewers'" 56 56 :class="tab === 'brewers' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 57 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 57 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 58 58 > 59 59 Brewers 60 60 </button> 61 61 <button 62 62 @click="tab = 'recipes'" 63 63 :class="tab === 'recipes' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 64 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 64 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 65 65 > 66 66 Recipes 67 67 </button>
+7 -7
internal/web/pages/my_coffee.templ
··· 40 40 // MyCoffeeTabs renders the tab navigation for My Coffee page 41 41 templ MyCoffeeTabs() { 42 42 <div class="mb-6 border-b-2 border-brown-300"> 43 - <nav class="-mb-px flex space-x-8 overflow-x-auto"> 43 + <nav class="-mb-px flex space-x-4 sm:space-x-8 overflow-x-auto"> 44 44 <button 45 45 @click="tab = 'brews'" 46 46 :class="tab === 'brews' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 47 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 47 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 48 48 > 49 49 Brews 50 50 </button> 51 51 <button 52 52 @click="tab = 'beans'" 53 53 :class="tab === 'beans' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 54 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 54 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 55 55 > 56 56 Beans 57 57 </button> 58 58 <button 59 59 @click="tab = 'roasters'" 60 60 :class="tab === 'roasters' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 61 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 61 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 62 62 > 63 63 Roasters 64 64 </button> 65 65 <button 66 66 @click="tab = 'grinders'" 67 67 :class="tab === 'grinders' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 68 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 68 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 69 69 > 70 70 Grinders 71 71 </button> 72 72 <button 73 73 @click="tab = 'brewers'" 74 74 :class="tab === 'brewers' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 75 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 75 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 76 76 > 77 77 Brewers 78 78 </button> 79 79 <button 80 80 @click="tab = 'recipes'" 81 81 :class="tab === 'recipes' ? 'border-brown-700 text-brown-900' : 'border-transparent text-brown-600 hover:text-brown-800 hover:border-brown-400'" 82 - class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" 82 + class="whitespace-nowrap py-4 px-2 sm:px-1 border-b-2 font-medium text-sm" 83 83 > 84 84 Recipes 85 85 </button>
+5 -5
internal/web/pages/profile.templ
··· 79 79 80 80 // ProfileStat renders a single stat card 81 81 templ ProfileStat(dataKey string, label string) { 82 - <div class="card-sm p-4 text-center"> 82 + <div class="card-sm p-4"> 83 83 <div class="text-2xl font-bold text-brown-800" data-stat={ dataKey }>-</div> 84 84 <div class="text-sm text-brown-700">{ label }</div> 85 85 </div> ··· 171 171 <div class="space-y-6"> 172 172 <!-- Open Bags Section --> 173 173 <div> 174 - <h4 class="text-lg font-semibold text-brown-900 mb-3">Open Bags</h4> 174 + <h2 class="text-lg font-semibold text-brown-900 mb-3">Open Bags</h2> 175 175 <div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> 176 176 for i := 0; i < 2; i++ { 177 177 <div class="feed-card feed-card-bean"> ··· 198 198 </div> 199 199 <!-- Roasters Section --> 200 200 <div> 201 - <h4 class="text-lg font-semibold text-brown-900 mb-3">Roasters</h4> 201 + <h2 class="text-lg font-semibold text-brown-900 mb-3">Roasters</h2> 202 202 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 203 203 for i := 0; i < 2; i++ { 204 204 <div class="feed-card feed-card-roaster"> ··· 222 222 <div class="space-y-6"> 223 223 <!-- Grinders Section --> 224 224 <div> 225 - <h4 class="text-lg font-semibold text-brown-900 mb-3">Grinders</h4> 225 + <h2 class="text-lg font-semibold text-brown-900 mb-3">Grinders</h2> 226 226 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 227 227 for i := 0; i < 2; i++ { 228 228 <div class="feed-card feed-card-grinder"> ··· 242 242 </div> 243 243 <!-- Brewers Section --> 244 244 <div> 245 - <h4 class="text-lg font-semibold text-brown-900 mb-3">Brewers</h4> 245 + <h2 class="text-lg font-semibold text-brown-900 mb-3">Brewers</h2> 246 246 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 247 247 for i := 0; i < 2; i++ { 248 248 <div class="feed-card feed-card-brewer">
+14 -13
internal/web/pages/recipe_explore.templ
··· 49 49 x-model="query" 50 50 @input.debounce.300ms="search()" 51 51 placeholder="Search recipes by name..." 52 + aria-label="Search recipes" 52 53 class="w-full form-input" 53 54 /> 54 55 </div> ··· 192 193 @click.stop 193 194 > 194 195 <template x-if="recipe.author_avatar"> 195 - <img :src="recipe.author_avatar" class="w-7 h-7 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''"/> 196 + <img :src="recipe.author_avatar" class="w-7 h-7 rounded-full object-cover" :alt="recipe.author_display || recipe.author_handle || ''" loading="lazy" width="28" height="28"/> 196 197 </template> 197 198 <template x-if="!recipe.author_avatar"> 198 199 <div class="w-7 h-7 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(recipe.author_display || recipe.author_handle || '?')[0].toUpperCase()"></div> ··· 230 231 <div class="flex items-center gap-3 pt-2 border-t border-brown-200/60 text-xs text-brown-500"> 231 232 <template x-if="recipe.brew_count > 0"> 232 233 <span class="flex items-center gap-1" :title="recipe.brew_count + ' brews'"> 233 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 234 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 234 235 <span x-text="recipe.brew_count + ' brew' + (recipe.brew_count !== 1 ? 's' : '')"></span> 235 236 </span> 236 237 </template> 237 238 <template x-if="recipe.fork_count > 0"> 238 239 <span class="flex items-center gap-1" :title="recipe.fork_count + ' forks'"> 239 - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 240 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 240 241 <span x-text="recipe.fork_count + ' fork' + (recipe.fork_count !== 1 ? 's' : '')"></span> 241 242 </span> 242 243 </template> 243 244 <template x-if="recipe.forker_avatars && recipe.forker_avatars.length > 0"> 244 245 <div class="flex -space-x-1.5 ml-auto"> 245 246 <template x-for="(avatar, i) in recipe.forker_avatars.slice(0, 3)" :key="i"> 246 - <img :src="avatar" class="w-5 h-5 rounded-full object-cover border border-white"/> 247 + <img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/> 247 248 </template> 248 249 </div> 249 250 </template> ··· 266 267 class="flex items-center gap-2 mt-1 group/author" 267 268 > 268 269 <template x-if="selectedRecipe.author_avatar"> 269 - <img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''"/> 270 + <img :src="selectedRecipe.author_avatar" class="w-6 h-6 rounded-full object-cover" :alt="selectedRecipe.author_display || ''" loading="lazy" width="24" height="24"/> 270 271 </template> 271 272 <template x-if="!selectedRecipe.author_avatar"> 272 273 <div class="w-6 h-6 rounded-full bg-brown-200 flex items-center justify-center text-brown-600 text-xs font-bold" x-text="(selectedRecipe.author_display || selectedRecipe.author_handle || '?')[0].toUpperCase()"></div> ··· 289 290 class="action-btn" 290 291 aria-label="More options" 291 292 > 292 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 293 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 293 294 <path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"></path> 294 295 </svg> 295 296 </button> ··· 306 307 > 307 308 <!-- Share --> 308 309 <button type="button" @click="shareRecipe(); actionsOpen = false" class="action-menu-item"> 309 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 310 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 310 311 <path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"></path> 311 312 </svg> 312 313 Share ··· 316 317 <div> 317 318 <div class="action-menu-divider"></div> 318 319 <button type="button" @click="openReport(); actionsOpen = false" class="action-menu-item"> 319 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 320 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 320 321 <path stroke-linecap="round" stroke-linejoin="round" d="M3 3v1.5M3 21v-6m0 0 2.77-.693a9 9 0 0 1 6.208.682l.108.054a9 9 0 0 0 6.086.71l3.114-.732a48.524 48.524 0 0 1-.005-10.499l-3.11.732a9 9 0 0 1-6.085-.711l-.108-.054a9 9 0 0 0-6.208-.682L3 4.5M3 15V4.5"></path> 321 322 </svg> 322 323 Report ··· 325 326 </template> 326 327 </div> 327 328 </div> 328 - <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold">&times;</button> 329 + <button @click="selectedRecipe = null" class="text-brown-500 hover:text-brown-700 text-lg font-bold" aria-label="Close recipe details">&times;</button> 329 330 </div> 330 331 </div> 331 332 <template x-if="getBrewerDisplay(selectedRecipe) !== '-'"> ··· 373 374 <div class="flex items-center gap-4 mb-4 text-sm text-brown-600"> 374 375 <template x-if="selectedRecipe.brew_count > 0"> 375 376 <span class="flex items-center gap-1.5"> 376 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 377 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15.362 5.214A8.252 8.252 0 0 1 12 21 8.25 8.25 0 0 1 6.038 7.047 8.287 8.287 0 0 0 9 9.601a8.983 8.983 0 0 1 3.361-6.867 8.21 8.21 0 0 0 3 2.48Z"></path></svg> 377 378 <span x-text="selectedRecipe.brew_count + ' brew' + (selectedRecipe.brew_count !== 1 ? 's' : '')"></span> 378 379 </span> 379 380 </template> 380 381 <template x-if="selectedRecipe.fork_count > 0"> 381 382 <span class="flex items-center gap-1.5"> 382 - <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 383 + <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"></path></svg> 383 384 <span x-text="selectedRecipe.fork_count + ' fork' + (selectedRecipe.fork_count !== 1 ? 's' : '')"></span> 384 385 <template x-if="selectedRecipe.forker_avatars && selectedRecipe.forker_avatars.length > 0"> 385 386 <div class="flex -space-x-1.5 ml-1"> 386 387 <template x-for="(avatar, i) in selectedRecipe.forker_avatars.slice(0, 5)" :key="i"> 387 - <img :src="avatar" class="w-5 h-5 rounded-full object-cover border border-white"/> 388 + <img :src="avatar" alt="" class="w-5 h-5 rounded-full object-cover border border-white" loading="lazy" width="20" height="20"/> 388 389 </template> 389 390 </div> 390 391 </template> ··· 513 514 <template x-if="success"> 514 515 <div class="text-center py-4"> 515 516 <div class="text-green-600 mb-2"> 516 - <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"> 517 + <svg class="w-12 h-12 mx-auto" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" aria-hidden="true"> 517 518 <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 518 519 </svg> 519 520 </div>
+18 -30
static/css/app.css
··· 236 236 .bg-white\/60 { background-color: var(--surface-bg) !important; } 237 237 238 238 /* Amber badges stay visible on dark */ 239 - .bg-amber-50 { background-color: rgba(251, 191, 36, 0.1) !important; } 240 - .bg-amber-100 { background-color: rgba(251, 191, 36, 0.15) !important; } 241 - .bg-amber-400 { background-color: #fbbf24 !important; } 242 - .text-amber-900 { color: #fde68a !important; } 243 - .text-amber-700 { color: #fcd34d !important; } 244 - .text-amber-600 { color: #fbbf24 !important; } 245 - .border-amber-400 { border-color: rgba(251, 191, 36, 0.4) !important; } 239 + .bg-amber-50 { background-color: rgba(251, 191, 36, 0.1); } 240 + .bg-amber-100 { background-color: rgba(251, 191, 36, 0.15); } 241 + .bg-amber-400 { background-color: #fbbf24; } 242 + .text-amber-900 { color: #fde68a; } 243 + .text-amber-700 { color: #fcd34d; } 244 + .text-amber-600 { color: #fbbf24; } 245 + .border-amber-400 { border-color: rgba(251, 191, 36, 0.4); } 246 246 247 247 /* Warning/alert backgrounds */ 248 248 .bg-green-50 { background-color: rgba(251, 191, 36, 0.08) !important; } ··· 361 361 :root[data-theme="dark"] .border-brown-300 { border-color: var(--card-border) !important; } 362 362 :root[data-theme="dark"] .bg-white { background-color: var(--card-bg) !important; } 363 363 :root[data-theme="dark"] .bg-white\/60 { background-color: var(--surface-bg) !important; } 364 - :root[data-theme="dark"] .bg-amber-50 { background-color: rgba(251, 191, 36, 0.1) !important; } 365 - :root[data-theme="dark"] .bg-amber-100 { background-color: rgba(251, 191, 36, 0.15) !important; } 366 - :root[data-theme="dark"] .text-amber-900 { color: #fde68a !important; } 367 - :root[data-theme="dark"] .text-amber-700 { color: #fcd34d !important; } 368 - :root[data-theme="dark"] .text-amber-600 { color: #fbbf24 !important; } 369 - :root[data-theme="dark"] .border-amber-400 { border-color: rgba(251, 191, 36, 0.4) !important; } 364 + :root[data-theme="dark"] .bg-amber-50 { background-color: rgba(251, 191, 36, 0.1); } 365 + :root[data-theme="dark"] .bg-amber-100 { background-color: rgba(251, 191, 36, 0.15); } 366 + :root[data-theme="dark"] .text-amber-900 { color: #fde68a; } 367 + :root[data-theme="dark"] .text-amber-700 { color: #fcd34d; } 368 + :root[data-theme="dark"] .text-amber-600 { color: #fbbf24; } 369 + :root[data-theme="dark"] .border-amber-400 { border-color: rgba(251, 191, 36, 0.4); } 370 370 :root[data-theme="dark"] .bg-green-50 { background-color: rgba(251, 191, 36, 0.08) !important; } 371 371 :root[data-theme="dark"] .hover\:bg-brown-200:hover, 372 372 :root[data-theme="dark"] .hover\:bg-brown-100:hover, ··· 714 714 .modal-backdrop { 715 715 @apply fixed inset-0 flex items-center justify-center z-50 p-4; 716 716 background: var(--modal-backdrop); 717 - backdrop-filter: blur(4px); 717 + /* backdrop-filter removed — solid overlay for performance */ 718 718 } 719 719 720 720 .modal-content { ··· 737 737 738 738 .modal-dialog::backdrop { 739 739 background: var(--modal-backdrop); 740 - backdrop-filter: blur(4px); 740 + /* backdrop-filter removed — solid overlay for performance */ 741 741 } 742 742 743 743 .modal-dialog .modal-content { ··· 1137 1137 } 1138 1138 1139 1139 .comment-textarea { 1140 - @apply w-full rounded-lg px-3 py-2.5 text-base resize-none transition-colors focus:ring-0 focus:outline-none; 1140 + @apply w-full rounded-lg px-3 py-2.5 text-base resize-none transition-colors focus:ring-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50; 1141 1141 background: var(--card-bg); 1142 1142 border: 1px solid var(--card-border); 1143 1143 color: var(--text-primary); ··· 1547 1547 0% { 1548 1548 transform: scale(1); 1549 1549 } 1550 - 15% { 1551 - transform: scale(1.3); 1552 - } 1553 - 30% { 1554 - transform: scale(0.9); 1555 - } 1556 - 45% { 1557 - transform: scale(1.15); 1558 - } 1559 - 60% { 1560 - transform: scale(0.95); 1561 - } 1562 - 75% { 1563 - transform: scale(1.05); 1550 + 40% { 1551 + transform: scale(1.25); 1564 1552 } 1565 1553 100% { 1566 1554 transform: scale(1);