fix(ui): use the full page width and fit phone screens

The page no longer caps its width. On very wide screens the attention list shows two columns of projects.

On phones the header, the health strip and the filter chips wrap or scroll inside their own box, and each item shows its age under the text. The side panel scrolls on its own.
This commit is contained in:
2026-10-02 04:40:27 +01:00
parent 6e305b860b
commit 1852ea0885
9 changed files with 44 additions and 23 deletions
+3 -3
View File
@@ -64,7 +64,7 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<main class="mx-auto max-w-7xl px-5 py-6 sm:px-8"> <main class="w-full px-4 py-4 sm:px-6 sm:py-6 lg:px-10">
<AppHeader <AppHeader
:last-refresh="state?.lastRefresh ?? ''" :last-refresh="state?.lastRefresh ?? ''"
:now-ms="nowMs" :now-ms="nowMs"
@@ -92,7 +92,7 @@ onBeforeUnmount(() => {
Fetching data from GitHub Fetching data from GitHub
</p> </p>
<template v-else> <template v-else>
<div class="mt-12"> <div class="mt-8 sm:mt-12">
<FleetOverview <FleetOverview
:crit="state.counts.crit" :crit="state.counts.crit"
:warn="state.counts.warn" :warn="state.counts.warn"
@@ -106,7 +106,7 @@ onBeforeUnmount(() => {
/> />
</div> </div>
<div <div
class="mt-14 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-12 lg:grid-cols-[minmax(0,1fr)_23rem]" class="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-10 sm:mt-14 lg:grid-cols-[minmax(0,1fr)_24rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]"
> >
<AttentionList <AttentionList
ref="list" ref="list"
+9 -4
View File
@@ -28,7 +28,7 @@ const nextIn = computed(() => {
</script> </script>
<template> <template>
<header class="flex items-center justify-between gap-4"> <header class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span
class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border" class="border-border bg-surface text-link flex size-9 items-center justify-center rounded-md border"
@@ -37,8 +37,12 @@ const nextIn = computed(() => {
</span> </span>
<span class="text-lg font-semibold tracking-tight">ghtracker</span> <span class="text-lg font-semibold tracking-tight">ghtracker</span>
</div> </div>
<div class="text-muted-foreground flex items-center gap-5 text-sm"> <div class="text-muted-foreground flex flex-wrap items-center gap-x-5 gap-y-1 text-sm">
<span v-if="updated" class="hidden sm:inline" data-test="updated"> <span
v-if="updated"
class="order-last w-full text-xs sm:order-none sm:w-auto sm:text-sm"
data-test="updated"
>
Scanned {{ updated }} ago Scanned {{ updated }} ago
<span class="text-muted-foreground/70"> · next in {{ nextIn }}</span> <span class="text-muted-foreground/70"> · next in {{ nextIn }}</span>
</span> </span>
@@ -52,7 +56,8 @@ const nextIn = computed(() => {
@click="$emit('rescan')" @click="$emit('rescan')"
> >
<FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" /> <FontAwesomeIcon :icon="faArrowsRotate" :spin="scanning" aria-hidden="true" />
{{ scanning ? 'Rescanning…' : 'Rescan repositories' }} <span class="hidden sm:inline">{{ scanning ? 'Rescanning…' : 'Rescan repositories' }}</span>
<span class="sm:hidden">{{ scanning ? 'Scanning…' : 'Rescan' }}</span>
</Button> </Button>
</div> </div>
</header> </header>
+1 -1
View File
@@ -43,7 +43,7 @@ defineExpose({ focusSearch: () => bar.value?.focus() })
<FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" /> <FontAwesomeIcon :icon="faCircleCheck" class="text-ok text-xl" aria-hidden="true" />
Nothing matches the current filters. Nothing matches the current filters.
</div> </div>
<div v-else class="flex flex-col gap-4"> <div v-else class="grid grid-cols-[minmax(0,1fr)] items-start gap-4 2xl:grid-cols-2">
<RepoGroup <RepoGroup
v-for="block in blocks" v-for="block in blocks"
:key="block.repo.fullName" :key="block.repo.fullName"
+7 -2
View File
@@ -12,7 +12,10 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
</script> </script>
<template> <template>
<li class="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-x-3 py-3" data-test="row"> <li
class="grid grid-cols-[2rem_minmax(0,1fr)] items-start gap-x-3 gap-y-1 py-3 sm:grid-cols-[2rem_minmax(0,1fr)_auto]"
data-test="row"
>
<span <span
class="flex size-8 items-center justify-center rounded-md" class="flex size-8 items-center justify-center rounded-md"
:class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'" :class="row.severity === 'crit' ? 'bg-crit/12 text-crit' : 'bg-warn/12 text-warn'"
@@ -31,7 +34,9 @@ const ratio = computed(() => secondsSince(props.row.since, props.nowMs) / props.
{{ shorten(row.detail, 120) }} {{ shorten(row.detail, 120) }}
</p> </p>
</div> </div>
<div class="flex flex-col items-end gap-1.5 pt-0.5"> <div
class="col-start-2 flex items-center gap-3 sm:col-start-auto sm:flex-col sm:items-end sm:gap-1.5 sm:pt-0.5"
>
<span class="text-muted-foreground font-mono text-xs whitespace-nowrap"> <span class="text-muted-foreground font-mono text-xs whitespace-nowrap">
{{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }} {{ isZeroTime(row.since) ? '' : ageSince(row.since, nowMs) }}
</span> </span>
+4 -1
View File
@@ -49,7 +49,7 @@ function onOwners(value: unknown): void {
<template> <template>
<div class="flex flex-col gap-3" data-test="filter-bar"> <div class="flex flex-col gap-3" data-test="filter-bar">
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<div class="relative min-w-56 flex-1"> <div class="relative min-w-0 basis-full sm:min-w-56 sm:flex-1 sm:basis-0">
<FontAwesomeIcon <FontAwesomeIcon
:icon="faMagnifyingGlass" :icon="faMagnifyingGlass"
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2" class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
@@ -75,6 +75,7 @@ function onOwners(value: unknown): void {
</div> </div>
<ToggleGroup <ToggleGroup
type="single" type="single"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline" variant="outline"
size="sm" size="sm"
:model-value="filters.severity" :model-value="filters.severity"
@@ -96,6 +97,7 @@ function onOwners(value: unknown): void {
<div class="flex flex-wrap items-center gap-x-5 gap-y-2"> <div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleGroup <ToggleGroup
type="multiple" type="multiple"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline" variant="outline"
size="sm" size="sm"
:model-value="filters.kinds" :model-value="filters.kinds"
@@ -117,6 +119,7 @@ function onOwners(value: unknown): void {
<ToggleGroup <ToggleGroup
v-if="owners.length > 1" v-if="owners.length > 1"
type="multiple" type="multiple"
class="max-w-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
variant="outline" variant="outline"
size="sm" size="sm"
:model-value="filters.owners" :model-value="filters.owners"
+7 -7
View File
@@ -57,7 +57,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
<template> <template>
<section <section
class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_20rem]" class="grid grid-cols-[minmax(0,1fr)] items-end gap-x-14 gap-y-8 lg:grid-cols-[minmax(0,1fr)_22rem]"
aria-label="Fleet status" aria-label="Fleet status"
> >
<div> <div>
@@ -68,22 +68,22 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
<template v-else>All {{ repos.length }} projects are healthy</template> <template v-else>All {{ repos.length }} projects are healthy</template>
</p> </p>
<h1 <h1
class="flex flex-wrap items-baseline gap-x-10 gap-y-1 font-semibold tracking-tight" class="flex flex-wrap items-baseline gap-x-6 gap-y-1 sm:gap-x-10 font-semibold tracking-tight"
data-test="headline" data-test="headline"
> >
<template v-if="crit > 0 || warn > 0"> <template v-if="crit > 0 || warn > 0">
<span v-if="crit > 0" class="text-crit"> <span v-if="crit > 0" class="text-crit">
<span class="text-7xl leading-none tabular-nums">{{ crit }}</span> <span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ crit }}</span>
<span class="ml-2 text-xl font-medium">critical</span> <span class="ml-2 text-xl font-medium">critical</span>
</span> </span>
<span v-if="warn > 0" class="text-warn"> <span v-if="warn > 0" class="text-warn">
<span class="text-7xl leading-none tabular-nums">{{ warn }}</span> <span class="text-5xl leading-none tabular-nums sm:text-7xl">{{ warn }}</span>
<span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span> <span class="ml-2 text-xl font-medium">{{ warn === 1 ? 'warning' : 'warnings' }}</span>
</span> </span>
</template> </template>
<span v-else class="text-ok text-7xl leading-none">All clear</span> <span v-else class="text-ok text-5xl leading-none sm:text-7xl">All clear</span>
</h1> </h1>
<div class="mt-8"> <div class="mt-6 sm:mt-8">
<FleetStrip <FleetStrip
:repos="repos" :repos="repos"
:counts="counts" :counts="counts"
@@ -101,7 +101,7 @@ const rows = computed<{ label: string; value: number; icon: IconDefinition; tone
<span class="flex items-center gap-1.5" <span class="flex items-center gap-1.5"
><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span ><span class="bg-ok/40 size-2 rounded-[2px]"></span>healthy</span
> >
<span class="ml-auto">Select a segment to focus a project</span> <span class="sm:ml-auto">Tap a segment to focus a project</span>
</p> </p>
</div> </div>
</div> </div>
+7 -2
View File
@@ -24,14 +24,19 @@ function summary(c: RepoCounts | undefined): string {
<template> <template>
<TooltipProvider :delay-duration="80"> <TooltipProvider :delay-duration="80">
<div class="flex gap-1" role="group" aria-label="Project health" data-test="strip"> <div
class="grid grid-cols-[repeat(auto-fill,minmax(1.5rem,1fr))] gap-1"
role="group"
aria-label="Project health"
data-test="strip"
>
<Tooltip v-for="repo in repos" :key="repo.fullName"> <Tooltip v-for="repo in repos" :key="repo.fullName">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
type="button" type="button"
:aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`" :aria-label="`${repo.fullName}: ${summary(counts.get(repo.fullName))}`"
:aria-pressed="focused === repo.fullName" :aria-pressed="focused === repo.fullName"
class="h-14 min-w-0 flex-1 rounded-[3px] transition-colors focus-visible:outline-offset-1" class="h-9 min-w-0 rounded-[3px] sm:h-11 transition-colors focus-visible:outline-offset-1"
:class="[ :class="[
SEGMENT[repo.health], SEGMENT[repo.health],
focused === repo.fullName focused === repo.fullName
+4 -1
View File
@@ -26,7 +26,10 @@ const LABEL = { crit: 'Critical', warn: 'Warning', ok: 'Healthy' } as const
> >
No projects match the search. No projects match the search.
</p> </p>
<ul v-else class="border-border bg-surface divide-border divide-y rounded-lg border"> <ul
v-else
class="border-border bg-surface divide-border max-h-[70vh] divide-y overflow-y-auto rounded-lg border lg:max-h-[calc(100vh-7rem)]"
>
<li <li
v-for="repo in repos" v-for="repo in repos"
:key="repo.fullName" :key="repo.fullName"
+2 -2
View File
@@ -39,12 +39,12 @@ describe('AppHeader', () => {
it('emits rescan and shows progress while a scan runs', async () => { it('emits rescan and shows progress while a scan runs', async () => {
const w = mount(AppHeader, { props: { ...base, manualRefresh: true } }) const w = mount(AppHeader, { props: { ...base, manualRefresh: true } })
expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories') expect(w.get('[data-test=rescan]').text()).toContain('Rescan repositories')
await w.get('[data-test=rescan]').trigger('click') await w.get('[data-test=rescan]').trigger('click')
expect(w.emitted('rescan')).toHaveLength(1) expect(w.emitted('rescan')).toHaveLength(1)
const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } }) const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } })
expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined() expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined()
expect(busy.get('[data-test=rescan]').text()).toBe('Rescanning…') expect(busy.get('[data-test=rescan]').text()).toContain('Rescanning…')
}) })
}) })