From b65a6e9ba38e52f7739e09651b5a9a7f18010e8a Mon Sep 17 00:00:00 2001 From: Daniele De Dominicis Date: Sat, 22 Aug 2026 13:13:31 +0000 Subject: [PATCH] Say where the app came from, in a footer on every screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Somebody can arrive in front of a running Activity Hub without having been the one who deployed it — a NAS in the house, a server set up years ago — and the running app is then their only clue. So the footer carries the link out to the project and its licence. On every screen, including the one that reports an unreachable API: a person whose stack will not come up is exactly the person who wants the repository, and that screen is where they are stuck. That needs a shell around all three states, so the page became a min-h-screen column with a growing body and the footer after it, rather than a footer appended to a full-height view where a short screen would leave it below the fold. The links are underlined rather than merely coloured. They sit in body ink, where colour alone would not say they are links, and this app's rule is that identity is never colour alone. `.muted` carries the descriptive half: 4.55:1 on the light surface and 7.29:1 on the dark one, both clear of AA. The layout cost one bug, found by measuring rather than by reading. Making the body a flex column made the dashboard a flex item, and a flex item takes its cross size from its content — so the activity table pushed the page to 339px inside a 320px column and dragged the document with it, the same overflow the table-scroll utility had just been written to stop. `*:w-full` gives every child an explicit width and takes the choice away. Third of a family in docs/DESIGN.md now: a grid item needs min-width:0 to be allowed to shrink, a flex item needs a width to be made to fill. The first-run form centred itself with min-h-screen, which would have pushed the footer off a short screen; it grows with the column instead. Verified centred at 800px and 1100px tall, and no horizontal overflow at 320, 360 or 390. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019mNxXmXqwMZf6MG8PoWbq4 --- README.md | 2 +- docs/DESIGN.md | 19 ++++++++ frontend/src/App.jsx | 19 ++++++++ frontend/src/components/FirstRunScreen.jsx | 2 +- frontend/src/components/Footer.jsx | 54 +++++++++++++++++++++ frontend/tests/App.test.jsx | 15 ++++++ frontend/tests/Footer.test.jsx | 56 ++++++++++++++++++++++ 7 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/Footer.jsx create mode 100644 frontend/tests/Footer.test.jsx diff --git a/README.md b/README.md index 87eb8f8..bfbcd68 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ creates the profile from `DEFAULT_FIRST_NAME` instead of the first-run screen. ```bash cd backend && ruff check . && ruff format --check . && pytest # 343 tests, no database needed -cd frontend && npm run lint && npm test # 223 tests, jsdom +cd frontend && npm run lint && npm test # 232 tests, jsdom ``` The backend suite runs against in-memory SQLite, so there is nothing to diff --git a/docs/DESIGN.md b/docs/DESIGN.md index dee1eac..610593f 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -338,6 +338,25 @@ propagation without clipping anything; `overflow-x: hidden` on `html`, on `body` and on the page container all changed nothing. That is why the `table-scroll` utility exists instead of a bare `overflow-x-auto`. +**A flex item's width is fit-content, not "fill the parent".** The footer +turned the page into a `min-h-screen` column so it sits at the bottom on a +short screen rather than below the fold. That made the dashboard a flex item, +and a flex item takes its cross size from its content — so the activity table +pushed it to 339px inside a 320px column and dragged the document out with it, +the same 19px overflow the `table-scroll` utility had just been written to +stop. `*:w-full` on the column gives every child an explicit width and takes +the choice away. Third of a family now: a grid item needs `min-width: 0` to be +allowed to shrink, a flex item needs a width to be made to fill, and neither +default is the one a page layout wants. + +**The app says where it came from, on every screen.** Somebody can arrive in +front of a running Activity Hub without having deployed it — a NAS in the +house, a server set up years ago — and the running app is then their only +clue, so the footer carries the link out to the project. Including on the +screen that reports an unreachable API: a person whose stack will not come up +is exactly the person who wants the repository, and that is where they are +stuck. + **A proportion bar that cannot show a proportion is decoration.** The zone rows are three columns where there is room, and on a phone the bar drops to its own line under the name and the time. Squeezed into a third of a 358px screen its diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d9e9e5a..e377958 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { errorMessage, fetchProfile } from './api/client' import Dashboard from './components/Dashboard' import FirstRunScreen from './components/FirstRunScreen' +import Footer from './components/Footer' /** * Decides between the first-run screen and the dashboard. @@ -26,6 +27,24 @@ export default function App() { } }, []) + // One shell around every state, so the footer is on all of them — and a + // column with a growing body rather than a full-height view plus a footer + // after it, which would push the footer below the fold on a short screen. + // + // `*:w-full` is load-bearing, in the same family as the `*:min-w-0` in the + // dashboard's grids. Whatever this box holds becomes a flex item, and a flex + // item's cross size is fit-content rather than "fill the parent" — so the + // activity table dragged the dashboard out to 339px inside a 320px column, + // and the document with it. An explicit width takes that away. + return ( +
+
{view(state, setState)}
+
+
+ ) +} + +function view(state, setState) { if (state.status === 'loading') { return (

diff --git a/frontend/src/components/FirstRunScreen.jsx b/frontend/src/components/FirstRunScreen.jsx index 44f2c09..d79ffba 100644 --- a/frontend/src/components/FirstRunScreen.jsx +++ b/frontend/src/components/FirstRunScreen.jsx @@ -28,7 +28,7 @@ export default function FirstRunScreen({ onCreated }) { } return ( -

+

Welcome to Activity Hub

Your activity files do not carry your name, so tell us once and we will not ask again. diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx new file mode 100644 index 0000000..73296fc --- /dev/null +++ b/frontend/src/components/Footer.jsx @@ -0,0 +1,54 @@ +const REPOSITORY = 'https://github.com/Danielededo/activity-hub' + +/** + * Says where this app came from. + * + * Someone can arrive in front of a running Activity Hub without having been + * the one who deployed it — a NAS in the house, a server someone set up years + * ago — and the running app is then the only clue they have. So the footer + * carries the link out to the project and its licence. + * + * Rendered on every screen, including the one that reports an unreachable API: + * a person whose stack will not come up is exactly the person who wants the + * repository, and that screen is where they are stuck. + */ +export default function Footer() { + return ( +

+
+

Activity Hub — your training, on your own machine.

+ + {/* Underlined, not merely coloured: these sit in body ink, so colour + alone would not say they are links. */} +

+ + + Source on GitHub + + + MIT licence + +

+
+
+ ) +} + +/** The mark people scan a footer for. Decorative: the link text carries it. */ +function GitHubMark() { + return ( + + ) +} diff --git a/frontend/tests/App.test.jsx b/frontend/tests/App.test.jsx index dd7448f..b950da8 100644 --- a/frontend/tests/App.test.jsx +++ b/frontend/tests/App.test.jsx @@ -61,6 +61,21 @@ describe('App', () => { expect(screen.getByRole('alert')).toHaveTextContent('Network Error') }) + it.each([ + ['the first-run screen', () => api.fetchProfile.mockResolvedValue(null)], + ['the dashboard', () => api.fetchProfile.mockResolvedValue(PROFILE)], + // The one that earns the shell: somebody whose stack will not come up is + // exactly the person who wants the repository, and this is where they stop. + ['the unreachable-API screen', () => api.fetchProfile.mockRejectedValue(new Error('nope'))], + ])('shows where the project lives on %s', async (_name, arrange) => { + arrange() + + render() + + await waitFor(() => expect(screen.getByRole('contentinfo')).toBeVisible()) + expect(screen.getByRole('link', { name: /source on github/i })).toBeVisible() + }) + it('moves on to the dashboard once the profile is created', async () => { api.fetchProfile.mockResolvedValue(null) api.createProfile.mockResolvedValue(PROFILE) diff --git a/frontend/tests/Footer.test.jsx b/frontend/tests/Footer.test.jsx new file mode 100644 index 0000000..7f6ede5 --- /dev/null +++ b/frontend/tests/Footer.test.jsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import Footer from '../src/components/Footer' + +const REPOSITORY = 'https://github.com/Danielededo/activity-hub' + +describe('Footer', () => { + it('is a footer landmark', () => { + render(