Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.7.21.0] - 2026-10-18

### Changed

- Settings now has the same sidebar as the other signed-in pages.
- Closing a project note puts the keyboard back on that card.
- The Add Repositories visibility filter is a list like the organization filter, and star counts use the same star icon as the rest of the app.

## [0.7.20.0] - 2026-10-18

### Changed
Expand Down
14 changes: 2 additions & 12 deletions TODOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,10 @@ From the `/ship` review of `fix/owner-filter-rollback-guard-sentry-tunnel`. None

From `/design-review` of the change for issue #213. The review found 27 issues and fixed 21; these are what is left. All of them are in the app as it was before that change. The full report with screenshots is in `~/.gstack/projects/laststance-gitbox/designs/design-audit-20261012/`.

### Smaller design gaps left from the 2026-10-12 review

**What:** (1) Replace `transition-all` (18 places) with the properties that change, and stop animating the sidebar's `width`, which from 768px moves the whole page on every frame (below, the sidebar lies over the page). (2) Return the focus to the card after every dialog that a card opens: `useCardDialogFocusReturn` is used by Move to Another Board and Remove from Board only. (3) Use one component for inline error messages and one pattern for a pending button; they are built by hand in each form. (4) `/settings` renders without the sidebar every other signed-in page has. (5) A card's menu has separators and a column's menu has none. (6) The picker has a native `<select>` next to a Radix select. (7) Star counts use an emoji where the rest of the app uses icons.

**Why:** Polish (FINDING-013, FINDING-023 and the consistency review). None of them stops anyone; together they are why the app feels slightly different from page to page.

**Context:** `src/components/Sidebar/Sidebar.tsx`, `src/hooks/board/useCardDialogFocusReturn.ts`, `src/app/settings/`, `src/components/Board/OverflowMenu.tsx`, `src/components/Board/StatusColumn.tsx`, `src/components/Board/AddRepositoryCombobox.tsx`.

**Effort:** M
**Priority:** P4
**Depends on:** None

## Completed

- [x] **Smaller design gaps left from the 2026-10-12 review**
**Completed:** v0.7.21.0 (2026-10-18). Settings has the sidebar, a closed note returns focus to the card, forms share one error and pending label, transitions name the properties that change, the column menu has a separator, and the picker uses a Radix visibility list and a lucide star.
- [x] **Preserve `?query` and `#hash` on silent refresh redirect** — [#177](https://github.com/laststance/gitbox/issues/177)
**Completed:** v0.3.3.0 (2026-10-07). The repository catalog caller now forwards the full board destination, with unit coverage and a real-account OAuth round trip confirming both values survive.
- [x] **Fix the hydration mismatch on the board page for returning users**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.7.20.0
0.7.21.0
128 changes: 128 additions & 0 deletions e2e/logged-in/add-repository-combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { Locator } from '@playwright/test'
import { test, expect } from '../fixtures/coverage'
import {
BOARD_IDS,
CARD_IDS,
TEST_USER_ID,
WORK_PROJECTS_STATUS_IDS,
createServiceRoleSupabaseClient,
Expand Down Expand Up @@ -1399,8 +1400,135 @@ test.describe('AddRepositoryCombobox - Escape', () => {
// Assert
await expect(searchBox).toBeHidden()
})

// Value: protects=Escape in the open Visibility list closes that list only;
// fails_when=the panel takes the Escape of the list and the picker is gone;
// why_new=Visibility is now a Radix select like Organization; seam=none
test('Escape in the open Visibility list closes the list, and the picker stays', async ({
page,
}) => {
// Arrange
await page.goto(BOARD_URL)
await expect(page.getByText('Pending')).toBeVisible({ timeout: 15000 })
await page.getByRole('button', { name: /add repositories/i }).click()
const searchBox = page.getByRole('textbox', {
name: 'Search repositories',
})
await expect(searchBox).toBeVisible()
const visibilityFilter = page.getByRole('combobox', {
name: 'Visibility filter',
})
await visibilityFilter.click()
const publicOption = page.getByRole('option', {
name: 'Public',
exact: true,
})
await expect(publicOption).toBeVisible()

// Act
await page.keyboard.press('Escape')

// Assert
await expect(publicOption).toBeHidden()
await expect(searchBox).toBeVisible()
})
})

test.describe('AddRepositoryCombobox - Visibility and stars', () => {
test.use({ storageState: 'e2e/.auth/user.json' })

const BOARD_URL = `/board/${BOARD_IDS.testBoard}`

test.beforeEach(async () => {
// Seed card 4 is laststance/nsx. Every other public catalog fixture is
// already on this board, so the picker has no public row until this card
// is gone.
await freeNsxOnTestBoard()
})

test.afterEach(async () => {
await resetRepoCards()
})

// Value: protects=Public hides the private fixture repo and Private shows it;
// fails_when=the filter is ignored or a native select cannot be driven;
// why_new=Visibility is now a Radix select; seam=none
test('Public hides the private fixture repository and Private shows it', async ({
page,
}) => {
// Arrange
await page.goto(BOARD_URL)
await expect(page.getByText('Pending')).toBeVisible({ timeout: 15000 })
await page.getByRole('button', { name: /add repositories/i }).click()
const visibilityFilter = page.getByRole('combobox', {
name: 'Visibility filter',
})
const privateRepo = page.getByRole('option', {
name: /testuser\/private-project/,
})
const publicRepo = page.getByRole('option', { name: /laststance\/nsx/ })
await expect(privateRepo).toBeVisible({ timeout: 10000 })
await expect(publicRepo).toBeVisible()

// Act
await visibilityFilter.click()
await page.getByRole('option', { name: 'Public', exact: true }).click()

// Assert
await expect(privateRepo).toBeHidden()
await expect(publicRepo).toBeVisible()

// Act
await visibilityFilter.click()
await page.getByRole('option', { name: 'Private', exact: true }).click()

// Assert
await expect(privateRepo).toBeVisible()
await expect(publicRepo).toBeHidden()
})

// Value: protects=an available fixture row shows a lucide star, not ⭐;
// fails_when=the emoji comes back; why_new=the leftover used ⭐; seam=none
test('shows a lucide star next to the fixture star count', async ({
page,
}) => {
// Arrange: laststance/nsx is free to add and has 85 stars
await page.goto(BOARD_URL)
await expect(page.getByText('Pending')).toBeVisible({ timeout: 15000 })
await page.getByRole('button', { name: /add repositories/i }).click()
const nsx = page.getByRole('option', { name: /laststance\/nsx/ })
await expect(nsx).toBeVisible({ timeout: 10000 })

// Act
const starCount = nsx.getByTestId('repo-star-count')

// Assert
await expect(starCount).toBeVisible()
await expect(starCount).toHaveText('85')
await expect(starCount.locator('svg')).toBeVisible()
await expect(starCount).not.toContainText('⭐')
})
})

/**
* Takes laststance/nsx off the Test Board so the picker can offer it.
*
* Seed card 4 is that repository. The Visibility and stars tests need a
* public catalog row that is not already placed; every other public fixture
* already sits on the board.
*
* @throws When the service-role delete fails.
*/
async function freeNsxOnTestBoard(): Promise<void> {
const { error } = await createServiceRoleSupabaseClient()
.from('repocard')
.delete()
.eq('id', CARD_IDS.card4)
if (error) {
throw new Error(`freeNsxOnTestBoard failed: ${error.message}`)
}
}

/**
* Places repositories on the Work Projects board, bypassing the app, so the
* picker of another board lists them under "Already placed elsewhere".
Expand Down
21 changes: 21 additions & 0 deletions e2e/logged-in/note-modal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ test.describe('NoteModal (Authenticated)', () => {
await expect(title).toBeVisible()
})

// Value: protects=Escape on the note hands the keyboard back to the card;
// fails_when=the dialog closes onto the page itself; why_new=NoteModal now
// uses useCardDialogFocusReturn; seam=none
test('Escape on the note returns focus to the fixture card', async ({
page,
}) => {
// Arrange
const card = page.locator(`[data-repo-card-id="${CARD_IDS.card1}"]`)
await expect(card).toBeVisible({ timeout: 10000 })
await card.getByRole('button', { name: 'Open note' }).click()
const dialog = page.getByTestId('note-modal')
await expect(dialog).toBeVisible({ timeout: 5000 })

// Act
await page.keyboard.press('Escape')

// Assert
await expect(dialog).toBeHidden()
await expect(card).toBeFocused()
})

test('should display Plate Editor in the modal', async ({ page }) => {
// Open NoteModal via Note button
const card = page.locator('[data-testid^="repo-card-"]').first()
Expand Down
1 change: 1 addition & 0 deletions e2e/logged-in/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test.describe('Settings Page (Authenticated)', () => {
// Should have settings heading
const heading = page.getByRole('heading', { name: /settings/i })
await expect(heading).toBeVisible()
await expect(page.getByTestId('sidebar-place')).toBeVisible()
})

test('should display display settings', async ({ page }) => {
Expand Down
5 changes: 3 additions & 2 deletions e2e/logged-in/sidebar-narrow-viewport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,16 @@ test.describe('Sidebar below 768px', () => {
expect(pageWidthPx).toBe(375)
})

// Value: protects=a key typed while the sidebar lies over a board or over the account page cannot reach that page, so Z does not undo a drag behind the sidebar; fails_when=one of these layouts wraps the sidebar in an element of its own, which leaves it no sibling to set aside; why_new=the tests above open the sidebar on the boards list only, and the unit tests render a page of their own beside it; seam=none; no_claim=that Z does nothing on an inert page, which useKanbanUndo.test.tsx checks
// Value: protects=a key typed while the sidebar lies over a board, the account page, or settings cannot reach that page, so Z does not undo a drag behind the sidebar; fails_when=one of these layouts wraps the sidebar in an element of its own, which leaves it no sibling to set aside; why_new=the tests above open the sidebar on the boards list only, and the unit tests render a page of their own beside it; seam=none; no_claim=that Z does nothing on an inert page, which useKanbanUndo.test.tsx checks
for (const { pageName, url } of [
{ pageName: 'a board', url: `/board/${BOARD_IDS.testBoard}` },
{ pageName: 'the account page', url: '/account' },
{ pageName: 'the settings page', url: '/settings' },
]) {
test(`opened over ${pageName}, it sets the page aside as on the boards list`, async ({
page,
}) => {
// Arrange: one of the two other layouts of the app that hold a sidebar
// Arrange: one of the other layouts of the app that hold a sidebar
await page.goto(url)
const content = page.locator('main')
const expandButton = page.getByRole('button', { name: 'Expand sidebar' })
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitbox",
"version": "0.7.20",
"version": "0.7.21",
"private": true,
"type": "module",
"description": "A web application for managing GitHub repositories in Kanban board format",
Expand Down
4 changes: 2 additions & 2 deletions src/app/(landing)/KanbanPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ export const KanbanPreview = memo(function KanbanPreview() {
}
}}
className={cn(
'rounded-lg border p-3 transition-all duration-200',
'rounded-lg border p-3 transition-colors duration-200',
// Empty placeholder column styling
column.id.startsWith('empty-')
? 'border-border/30 border-dashed bg-transparent'
Expand Down Expand Up @@ -471,7 +471,7 @@ export const KanbanPreview = memo(function KanbanPreview() {
}}
onDragEnd={handleCardDragEnd}
className={cn(
'rounded-lg border p-3 transition-all duration-200',
'rounded-lg border p-3 transition-colors duration-200',
'cursor-grab hover:scale-[1.02] hover:shadow-md active:cursor-grabbing',
card.color,
draggedCard?.id === card.id && 'opacity-50',
Expand Down
3 changes: 2 additions & 1 deletion src/app/boards/new/CreateBoardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
OUTCOME_UNKNOWN_TEXT_CLASS,
} from '@/lib/constants/outcome-unknown'
import { areServerRenderedPropsEqual } from '@/lib/utils/are-server-rendered-props-equal'
import { pendingButtonLabel } from '@/lib/utils/pending-button-label'
import { reloadPage } from '@/lib/utils/reload-page'
import { requestBoardCreation } from '@/lib/utils/request-board-creation'
import { BOARD_NAME_MAX_LENGTH, boardNameSchema } from '@/lib/validations/board'
Expand Down Expand Up @@ -260,7 +261,7 @@ export const CreateBoardForm = memo(function CreateBoardForm() {
</Button>
) : (
<Button type="submit" disabled={isPending} className="flex-1">
{isPending ? 'Creating…' : 'Create Board'}
{pendingButtonLabel(isPending, 'Create Board', 'Creating…')}
</Button>
)}
</div>
Expand Down
37 changes: 37 additions & 0 deletions src/app/settings/SettingsLayoutClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client'

import React, { memo } from 'react'

import { Sidebar } from '@/components/Sidebar'
import { ThemeApplicator } from '@/components/ThemeApplicator'
import { areServerRenderedPropsEqual } from '@/lib/utils/are-server-rendered-props-equal'

interface SettingsLayoutClientProps {
children: React.ReactNode
userName?: string
userAvatar?: string
}

/**
* Settings layout chrome: sidebar beside the page.
*
* Copied from {@link AccountLayoutClient} so `/settings` is a sibling of
* the sidebar. A sidebar that lies over the page can then set `inert` on
* `main`. {@link areServerRenderedPropsEqual} keeps the client tree when
* the server sends the layout again.
*/
export const SettingsLayoutClient = memo(function SettingsLayoutClient({
children,
userName,
userAvatar,
}: SettingsLayoutClientProps) {
return (
<>
<ThemeApplicator />
<div className="flex h-screen">
<Sidebar userName={userName} userAvatar={userAvatar} />
<main className="bg-background flex-1 overflow-auto">{children}</main>
</div>
</>
)
}, areServerRenderedPropsEqual)
20 changes: 10 additions & 10 deletions src/app/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
/**
* Settings Layout
*
* Layout for /settings route
* - Theme application for authenticated users
* - Auth gate via JWT claims (~5ms WebCrypto verify) instead of the ~50ms
* `auth.getUser()` round-trip. Claims body is unused here; the call exists
* for its redirect side effect when the JWT is missing or expired.
* Same sidebar chrome as `/account` and `/boards`. The sidebar and the
* page are siblings so a sidebar that lies over the page can set `inert`
* on `main`.
*/

import { ThemeApplicator } from '@/components/ThemeApplicator'
import { requireClaims } from '@/lib/auth/require-claims'
import { getClaimsDisplayInfo } from '@/lib/utils/get-claims-display-info'

import { SettingsLayoutClient } from './SettingsLayoutClient'

export default async function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
await requireClaims()
const { claims } = await requireClaims()
const { userName, userAvatar } = getClaimsDisplayInfo(claims)

return (
<>
<ThemeApplicator />
<SettingsLayoutClient userName={userName} userAvatar={userAvatar}>
{children}
</>
</SettingsLayoutClient>
)
}
Loading
Loading