my prefect server setup prefect-metrics.waow.tech
python orchestration
0
fork

Configure Feed

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

hub header rework + mobile responsive + accessibility

- header: semantic HTML with nav links to prefect server and source
- filter bar: full-width search on mobile, flex selects, aria labels
- card table: mobile card view (MobileCard.svelte) with sort control,
aria-sort on desktop table headers
- stat bar: tabular-nums, role="group"
- card row: better contrast on timestamps
- app.css: global focus-visible outline, prefers-reduced-motion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 1b347b62 3924f982

+178 -14
+18
web/src/app.css
··· 1 1 @tailwind base; 2 2 @tailwind components; 3 3 @tailwind utilities; 4 + 5 + @layer base { 6 + *:focus-visible { 7 + outline: 2px solid rgb(99 102 241 / 0.5); 8 + outline-offset: 2px; 9 + } 10 + 11 + @media (prefers-reduced-motion: reduce) { 12 + *, 13 + *::before, 14 + *::after { 15 + animation-duration: 0.01ms !important; 16 + animation-iteration-count: 1 !important; 17 + transition-duration: 0.01ms !important; 18 + scroll-behavior: auto !important; 19 + } 20 + } 21 + }
+1 -1
web/src/lib/components/CardRow.svelte
··· 67 67 <!-- relevance --> 68 68 <td class="px-4 py-3 text-right"> 69 69 <div class="text-gray-300 font-mono text-xs">{card.score.toFixed(1)}</div> 70 - <div class="text-gray-500 text-xs">{timeAgo(card.updated)}</div> 70 + <div class="text-gray-400 text-xs">{timeAgo(card.updated)}</div> 71 71 </td> 72 72 </tr>
+43 -1
web/src/lib/components/CardTable.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Card } from '$lib/types'; 3 3 import CardRow from '$lib/components/CardRow.svelte'; 4 + import MobileCard from '$lib/components/MobileCard.svelte'; 4 5 5 6 let { cards }: { cards: Card[] } = $props(); 6 7 ··· 39 40 if (sortCol !== col) return ''; 40 41 return sortDir === 'asc' ? ' ↑' : ' ↓'; 41 42 } 43 + 44 + function ariaSort(col: string): 'ascending' | 'descending' | 'none' { 45 + if (sortCol !== col) return 'none'; 46 + return sortDir === 'asc' ? 'ascending' : 'descending'; 47 + } 42 48 </script> 43 49 44 - <div class="bg-gray-900 rounded-lg overflow-hidden"> 50 + <!-- desktop table --> 51 + <div class="bg-gray-900 rounded-lg overflow-hidden hidden md:block"> 45 52 <table class="w-full text-sm"> 46 53 <thead> 47 54 <tr class="border-b border-gray-800"> 48 55 <th 49 56 class="text-left px-4 py-3 font-normal cursor-pointer {sortCol === 'source' ? 'text-gray-200' : 'text-gray-400'}" 50 57 onclick={() => toggle('source')} 58 + aria-sort={ariaSort('source')} 51 59 > 52 60 source{indicator('source')} 53 61 </th> 54 62 <th 55 63 class="text-left px-4 py-3 font-normal cursor-pointer {sortCol === 'item' ? 'text-gray-200' : 'text-gray-400'}" 56 64 onclick={() => toggle('item')} 65 + aria-sort={ariaSort('item')} 57 66 > 58 67 item{indicator('item')} 59 68 </th> 60 69 <th 61 70 class="text-left px-4 py-3 font-normal cursor-pointer {sortCol === 'author' ? 'text-gray-200' : 'text-gray-400'}" 62 71 onclick={() => toggle('author')} 72 + aria-sort={ariaSort('author')} 63 73 > 64 74 author{indicator('author')} 65 75 </th> ··· 67 77 <th 68 78 class="text-right px-4 py-3 font-normal cursor-pointer {sortCol === 'score' ? 'text-gray-200' : 'text-gray-400'}" 69 79 onclick={() => toggle('score')} 80 + aria-sort={ariaSort('score')} 70 81 > 71 82 relevance{indicator('score')} 72 83 </th> ··· 79 90 </tbody> 80 91 </table> 81 92 </div> 93 + 94 + <!-- mobile cards --> 95 + <div class="md:hidden space-y-3"> 96 + <div class="flex items-center gap-2 mb-2"> 97 + <select 98 + bind:value={sortCol} 99 + aria-label="sort by" 100 + class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-gray-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 flex-1 min-w-0" 101 + > 102 + <option value="score">relevance</option> 103 + <option value="source">source</option> 104 + <option value="item">item</option> 105 + <option value="author">author</option> 106 + </select> 107 + <button 108 + onclick={() => sortDir = sortDir === 'asc' ? 'desc' : 'asc'} 109 + class="p-2 bg-gray-800 border border-gray-700 rounded text-gray-400 hover:text-gray-200 hover:bg-gray-700 focus-visible:ring-2 focus-visible:ring-indigo-500/50 transition-colors" 110 + aria-label="toggle sort direction: {sortDir === 'asc' ? 'ascending' : 'descending'}" 111 + > 112 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" 113 + class={sortDir === 'asc' ? 'rotate-180' : ''} 114 + > 115 + <path d="M12 5v14" /> 116 + <path d="M19 12l-7 7-7-7" /> 117 + </svg> 118 + </button> 119 + </div> 120 + {#each sorted as card (card.id)} 121 + <MobileCard {card} /> 122 + {/each} 123 + </div>
+10 -6
web/src/lib/components/FilterBar.svelte
··· 20 20 } = $props(); 21 21 </script> 22 22 23 - <div class="flex flex-row gap-3 items-center flex-wrap mb-6"> 23 + <div class="flex flex-row gap-3 items-center flex-wrap mb-6" role="search"> 24 24 <input 25 25 bind:value={search} 26 26 placeholder="search..." 27 - class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-500 w-64" 27 + aria-label="search items" 28 + class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 w-full sm:w-64" 28 29 /> 29 30 30 31 <select 31 32 bind:value={sourceFilter} 32 - class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-gray-500 w-auto" 33 + aria-label="filter by source" 34 + class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-gray-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 flex-1 sm:flex-none min-w-0" 33 35 > 34 36 <option value="">all sources</option> 35 37 {#each sources as source (source)} ··· 39 41 40 42 <select 41 43 bind:value={kindFilter} 42 - class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-gray-500 w-auto" 44 + aria-label="filter by kind" 45 + class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-gray-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 flex-1 sm:flex-none min-w-0" 43 46 > 44 47 <option value="">all kinds</option> 45 48 {#each kinds as kind (kind)} ··· 49 52 50 53 <select 51 54 bind:value={tagFilter} 52 - class="bg-gray-800 border border-gray-700 rounded px-3 py-1.5 text-sm text-gray-200 focus:outline-none focus:border-gray-500 w-auto" 55 + aria-label="filter by tag" 56 + class="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-gray-500 focus-visible:ring-2 focus-visible:ring-indigo-500/50 flex-1 sm:flex-none min-w-0" 53 57 > 54 58 <option value="">all tags</option> 55 59 {#each tags as tag (tag)} ··· 57 61 {/each} 58 62 </select> 59 63 60 - <span class="text-xs text-gray-500 ml-auto">{count} items</span> 64 + <span class="text-xs text-gray-500 ml-auto" aria-live="polite">{count} items</span> 61 65 </div>
+67
web/src/lib/components/MobileCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Card } from '$lib/types'; 3 + import SourceBadge from '$lib/components/SourceBadge.svelte'; 4 + import { hashColor, timeAgo, parseInlineCode, authorUrl, originUrl } from '$lib/format'; 5 + 6 + let { card }: { card: Card } = $props(); 7 + 8 + let segments = $derived(parseInlineCode(card.title)); 9 + let author = $derived(authorUrl(card)); 10 + let origin = $derived(originUrl(card)); 11 + </script> 12 + 13 + <div class="bg-gray-900 rounded-lg p-4 space-y-2"> 14 + <div class="flex items-center justify-between gap-2"> 15 + <div class="flex items-center gap-2 min-w-0"> 16 + <SourceBadge kind={card.source} /> 17 + {#if card.meta.repo} 18 + <a href={origin} target="_blank" rel="noopener noreferrer" class="truncate"> 19 + <span class="inline-block px-2 py-0.5 rounded-full text-xs border {hashColor(String(card.meta.repo))}"> 20 + {card.meta.repo} 21 + </span> 22 + </a> 23 + {/if} 24 + </div> 25 + <div class="text-right shrink-0"> 26 + <div class="text-gray-300 font-mono text-xs">{card.score.toFixed(1)}</div> 27 + <div class="text-gray-400 text-xs">{timeAgo(card.updated)}</div> 28 + </div> 29 + </div> 30 + 31 + <a 32 + href={card.url} 33 + target="_blank" 34 + rel="noopener noreferrer" 35 + class="block text-gray-200 hover:text-white hover:underline text-sm leading-snug" 36 + > 37 + <span class="text-gray-400">#{card.meta.number}</span> 38 + {#each segments as seg, i (i)} 39 + {#if seg.code} 40 + <code class="bg-gray-700/60 px-1 rounded text-xs">{seg.code}</code> 41 + {:else} 42 + {seg.text} 43 + {/if} 44 + {/each} 45 + </a> 46 + 47 + <div class="flex items-center justify-between gap-2 text-xs"> 48 + <div> 49 + {#if card.meta.user} 50 + {#if author} 51 + <a href={author} class="text-gray-400 hover:text-gray-200" target="_blank" rel="noopener noreferrer"> 52 + @{card.meta.user} 53 + </a> 54 + {:else} 55 + <span class="text-gray-400">@{card.meta.user}</span> 56 + {/if} 57 + {/if} 58 + </div> 59 + <div class="flex flex-wrap justify-end gap-1"> 60 + {#each card.tags as tag (tag)} 61 + <span class="inline-block px-2 py-0.5 rounded-full text-xs border {hashColor(tag)}"> 62 + {tag} 63 + </span> 64 + {/each} 65 + </div> 66 + </div> 67 + </div>
+2 -2
web/src/lib/components/StatBar.svelte
··· 7 7 let { entries }: { entries: Entry[] } = $props(); 8 8 </script> 9 9 10 - <div class="grid grid-cols-2 sm:grid-cols-4 gap-4"> 10 + <div class="grid grid-cols-2 sm:grid-cols-4 gap-4" role="group" aria-label="dashboard statistics"> 11 11 {#each entries as entry (entry.label)} 12 12 <div class="bg-gray-800 rounded-lg px-5 py-4"> 13 - <p class="text-3xl font-semibold text-gray-100">{entry.value}</p> 13 + <p class="text-3xl font-semibold text-gray-100 tabular-nums">{entry.value}</p> 14 14 <p class="text-xs text-gray-400 mt-1 uppercase tracking-wider">{entry.label}</p> 15 15 </div> 16 16 {/each}
+37 -4
web/src/routes/+layout.svelte
··· 4 4 </script> 5 5 6 6 <div class="bg-gray-950 text-gray-100 min-h-screen"> 7 - <nav class="px-6 py-4 border-b border-gray-800/50"> 8 - <h1 class="text-xl font-light tracking-widest text-gray-500 lowercase">hub</h1> 9 - </nav> 10 - {@render children()} 7 + <header class="border-b border-gray-800/50"> 8 + <nav aria-label="main" class="max-w-screen-xl mx-auto flex items-center justify-between px-6 py-4"> 9 + <a href="/" class="text-xl font-light tracking-widest text-gray-500 lowercase hover:text-gray-300 focus-visible:text-gray-300 transition-colors">hub</a> 10 + <div class="flex items-center gap-1"> 11 + <a 12 + href="https://prefect-server.waow.tech" 13 + target="_blank" 14 + rel="noopener noreferrer" 15 + class="p-2 text-gray-500 hover:text-gray-300 hover:bg-gray-800/60 rounded-lg transition-colors" 16 + aria-label="prefect server" 17 + > 18 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 19 + <rect x="2" y="3" width="20" height="6" rx="1" /> 20 + <rect x="2" y="15" width="20" height="6" rx="1" /> 21 + <path d="M12 9v6" /> 22 + <path d="M6 9v3a3 3 0 0 0 3 3" /> 23 + <path d="M18 9v3a3 3 0 0 1-3 3" /> 24 + </svg> 25 + </a> 26 + <a 27 + href="https://tangled.org/zzstoatzz.io/my-prefect-server" 28 + target="_blank" 29 + rel="noopener noreferrer" 30 + class="p-2 text-gray-500 hover:text-gray-300 hover:bg-gray-800/60 rounded-lg transition-colors" 31 + aria-label="source code" 32 + > 33 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 34 + <polyline points="16 18 22 12 16 6" /> 35 + <polyline points="8 6 2 12 8 18" /> 36 + </svg> 37 + </a> 38 + </div> 39 + </nav> 40 + </header> 41 + <main class="max-w-screen-xl mx-auto"> 42 + {@render children()} 43 + </main> 11 44 </div>