feat: add minimal reader and shared account navigation

This commit is contained in:
2026-09-28 17:53:38 +09:00
parent 96e545401e
commit 4e8957c94a
15 changed files with 844 additions and 96 deletions
+8
View File
@@ -1,4 +1,12 @@
[ [
{
"date": "2026-09-28",
"macrostructure": "sidebar-09 Inbox",
"theme": "shadcn base-nova neutral",
"scope": "inbox",
"enrichment": "none",
"brief": "Shared navigation with Twitter Lite, inferred-interest article list and reading pane; no manual interest settings"
},
{ {
"date": "2026-09-28", "date": "2026-09-28",
"macrostructure": "sidebar-09 Workbench", "macrostructure": "sidebar-09 Workbench",
+8 -1
View File
@@ -23,7 +23,14 @@ bound to a connection account, so platforms and multiple accounts work side by s
- Saved chat history with deck, account and citation restoration across devices - Saved chat history with deck, account and citation restoration across devices
and backend restarts and backend restarts
Both `/` and `/deck` open the deck workspace. The separate reader, search, Both `/` and `/deck` open the deck workspace. A shared sidebar also opens
the article reader at `/inbox`. This UI-only prototype shows articles inferred
from past reading, searches, and research, with no manual interest settings.
Articles are fictional samples; search and article selection work locally.
On mobile, the article list and reader switch views. The shared sidebar’s bottom
avatar opens connected-account management from either workspace.
The former SNS reader, search,
list, user-profile, and conversation routes have been removed. Original-post list, user-profile, and conversation routes have been removed. Original-post
links on post cards open their source site; SNS reply threads are not rendered links on post cards open their source site; SNS reply threads are not rendered
inside the app. inside the app.
+5 -2
View File
@@ -3,8 +3,11 @@
## Workspace ## Workspace
Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui
sidebar based on `sidebar-09` switches between research chat and deck management. sidebar based on `sidebar-09` shares its navigation rail with the article reader
Deck management switches decks, adds columns, and manages connected accounts. Up to six ordered columns scroll independently; only the active deck at `/inbox`. Within Twitter Lite, it switches between research chat and deck management.
Deck management switches decks and adds columns. The avatar at the bottom of the
shared sidebar opens connected-account management in a dialog from either workspace.
Up to six ordered columns scroll independently; only the active deck
mounts and fetches its columns. On mobile, the sidebar opens as a sheet. mounts and fetches its columns. On mobile, the sidebar opens as a sheet.
shadcn Base UI dialogs handle creation and editing, with Escape and focus shadcn Base UI dialogs handle creation and editing, with Escape and focus
restoration. Column menus offer editing, ordering, and deletion; refresh and restoration. Column menus offer editing, ordering, and deletion; refresh and
+34 -3
View File
@@ -1,12 +1,16 @@
import { expect, test } from '../fixtures' import { expect, test } from '../fixtures'
import { showDeckManagement } from '../workspace'
test('opens account management after hydration without inheriting real OAuth configuration', async ({ test('opens account management after hydration without inheriting real OAuth configuration', async ({
page, page,
}) => { }) => {
await page.goto('/') await page.goto('/')
await showDeckManagement(page) await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' }) const manage = page.getByRole('button', { name: '接続アカウントを管理' })
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
}
await expect(manage).toBeEnabled() await expect(manage).toBeEnabled()
await manage.click() await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' }) const dialog = page.getByRole('dialog', { name: '接続アカウント' })
@@ -22,6 +26,27 @@ test('opens account management after hydration without inheriting real OAuth con
await expect(manage).toBeFocused() await expect(manage).toBeFocused()
}) })
test('opens account management from the inbox footer and restores focus on close', async ({
page,
a11y,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
await expect(manage).toBeVisible()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused()
await expect(page.getByRole('textbox', { name: '記事を検索' })).toBeVisible()
})
test('rejects an unbound OAuth callback without contacting an instance', async ({ test('rejects an unbound OAuth callback without contacting an instance', async ({
page, page,
}) => { }) => {
@@ -30,7 +55,13 @@ test('rejects an unbound OAuth callback without contacting an instance', async (
await expect(page.getByRole('alert')).toContainText( await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。', 'Mastodonの認可を完了できませんでした。',
) )
await showDeckManagement(page) await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
}
await expect( await expect(
page.getByRole('button', { name: '接続アカウントを管理' }), page.getByRole('button', { name: '接続アカウントを管理' }),
).toBeEnabled() ).toBeEnabled()
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from '../fixtures'
test('selects articles and keeps the reader focused on their content', async ({
page,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await expect(rows).toHaveCount(9)
const articleTitle = 'エージェントに渡す文脈は、短くてもよい'
await rows.filter({ hasText: articleTitle }).click()
const reader = page.getByRole('article', { name: articleTitle, exact: true })
await expect(reader).toBeVisible()
await expect(
reader.getByRole('heading', { name: articleTitle, exact: true }),
).toBeVisible()
await expect(
reader.getByText(
'まず目的を一文で書き、次に変えてはいけない条件を添える。',
{ exact: false },
),
).toBeVisible()
await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0)
await expect(page.getByText('モック', { exact: true })).toHaveCount(0)
await expect(
page.getByText('この記事が届いた理由', { exact: true }),
).toHaveCount(0)
await expect(
page.getByRole('button', { name: /あなたへ|あとで読む|未読のみ/ }),
).toHaveCount(0)
const back = page.getByRole('button', { name: '記事一覧に戻る' })
if (await back.isVisible()) await back.click()
await rows
.filter({ hasText: '3カラムの余白が、情報を読むリズムをつくる' })
.click()
await expect(
page.getByRole('article', {
name: '3カラムの余白が、情報を読むリズムをつくる',
exact: true,
}),
).toBeVisible()
})
test('recovers from empty search and shares navigation with Twitter Lite', async ({
page,
a11y,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await page
.getByRole('textbox', { name: '記事を検索' })
.fill('存在しない検索語xyz')
await expect(rows).toHaveCount(0)
await expect(
page.getByText('記事がありません', { exact: true }),
).toBeVisible()
await page.getByRole('button', { name: '検索をクリア' }).click()
await expect(rows).toHaveCount(9)
expect((await a11y().analyze()).violations).toEqual([])
await expect(
page.getByRole('button', { name: '興味を編集', exact: true }),
).toHaveCount(0)
await page.getByRole('link', { name: 'Twitter Lite', exact: true }).click()
await expect(page).toHaveURL(/\/deck$/)
await page.getByRole('link', { name: 'リーダー', exact: true }).click()
await expect(page).toHaveURL(/\/inbox$/)
await expect(rows).toHaveCount(9)
})
test('keeps article navigation readable without horizontal overflow', async ({
page,
a11y,
}, testInfo) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 })
await expect(
page.getByRole('textbox', { name: '記事を検索' }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.inbox-article-row').first().click()
const reader = page.getByRole('article', {
name: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
exact: true,
})
await expect(reader).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({
path: testInfo.outputPath(`inbox-reader-${width}.png`),
})
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: '記事一覧に戻る' })
if (await back.isVisible()) await back.click()
}
})
+50 -63
View File
@@ -1,14 +1,8 @@
import { Columns3, MessageSquare, PanelLeftClose, Search } from 'lucide-react' import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react'
import { type CSSProperties, type ReactNode, useState } from 'react' import { type CSSProperties, type ReactNode, useState } from 'react'
import { import {
Sidebar, Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarInset, SidebarInset,
SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
@@ -16,8 +10,10 @@ import {
useSidebar, useSidebar,
} from '#/components/ui/sidebar' } from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip' import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
type ShellProps = { type ShellProps = {
hasTemporaryDecks: boolean
sidebar: ReactNode sidebar: ReactNode
researchChat: (closeMobileSidebar: () => void) => ReactNode researchChat: (closeMobileSidebar: () => void) => ReactNode
children: ReactNode children: ReactNode
@@ -28,7 +24,12 @@ export function AppShell(props: ShellProps) {
<TooltipProvider delay={800}> <TooltipProvider delay={800}>
<SidebarProvider <SidebarProvider
className="workspace h-dvh min-h-0 overflow-hidden" className="workspace h-dvh min-h-0 overflow-hidden"
style={{ '--sidebar-width': '25rem' } as CSSProperties} style={
{
'--sidebar-width': '25rem',
'--sidebar-width-icon': '4rem',
} as CSSProperties
}
> >
<Workspace {...props} /> <Workspace {...props} />
</SidebarProvider> </SidebarProvider>
@@ -36,7 +37,12 @@ export function AppShell(props: ShellProps) {
) )
} }
function Workspace({ sidebar, researchChat, children }: ShellProps) { function Workspace({
sidebar,
researchChat,
children,
hasTemporaryDecks,
}: ShellProps) {
const [panel, setPanel] = useState<'chat' | 'decks'>('chat') const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar() const { setOpen, setOpenMobile, isMobile } = useSidebar()
const navigation = [ const navigation = [
@@ -50,61 +56,42 @@ function Workspace({ sidebar, researchChat, children }: ShellProps) {
collapsible="icon" collapsible="icon"
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row" className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
> >
<Sidebar <WorkspaceNavigation
collapsible="none" hasTemporaryDecks={hasTemporaryDecks}
className="w-[calc(var(--sidebar-width-icon)+1px)]! shrink-0 border-r" footer={
<SidebarMenuItem>
<SidebarMenuButton
aria-label="サイドバーを閉じる"
tooltip={{ children: 'サイドバーを閉じる', hidden: false }}
onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false)
}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
</SidebarMenuButton>
</SidebarMenuItem>
}
> >
<SidebarHeader> {navigation.map((item) => (
<div <SidebarMenuItem key={item.id}>
className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground" <SidebarMenuButton
role="img" aria-label={item.title}
aria-label="Twitter Lite" tooltip={{ children: item.title, hidden: false }}
> isActive={panel === item.id}
<Search className="size-4" aria-hidden="true" /> onClick={() => {
</div> setPanel(item.id)
</SidebarHeader> if (isMobile) setOpenMobile(true)
<SidebarContent> else setOpen(true)
<SidebarGroup> }}
<SidebarGroupContent> className="size-10 justify-center p-0"
<SidebarMenu> >
{navigation.map((item) => ( <item.icon />
<SidebarMenuItem key={item.id}> <span className="sr-only">{item.title}</span>
<SidebarMenuButton </SidebarMenuButton>
aria-label={item.title} </SidebarMenuItem>
tooltip={{ children: item.title, hidden: false }} ))}
isActive={panel === item.id} </WorkspaceNavigation>
onClick={() => {
setPanel(item.id)
if (isMobile) setOpenMobile(true)
else setOpen(true)
}}
className="px-2"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
aria-label="サイドバーを閉じる"
tooltip="サイドバーを閉じる"
onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false)
}
>
<PanelLeftClose />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
<Sidebar <Sidebar
collapsible="none" collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden" className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
+4 -1
View File
@@ -1,3 +1,4 @@
import { cn } from 'cn'
import { Icon } from './icon' import { Icon } from './icon'
import { Button } from './ui/button' import { Button } from './ui/button'
import { import {
@@ -12,8 +13,10 @@ export function Dialog({
title, title,
onClose, onClose,
children, children,
className,
}: { }: {
title: string title: string
className?: string
onClose: () => void onClose: () => void
children: React.ReactNode children: React.ReactNode
}) { }) {
@@ -26,7 +29,7 @@ export function Dialog({
> >
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
className="max-h-[calc(100dvh-2rem)] overflow-y-auto" className={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto', className)}
aria-describedby={undefined} aria-describedby={undefined}
> >
<DialogHeader> <DialogHeader>
+119
View File
@@ -0,0 +1,119 @@
import { Link, useLocation } from '@tanstack/react-router'
import { Inbox, Layers3, Search } from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from '#/components/ui/sidebar'
import { ConnectionManagerDialog } from '#/features/connections/connection-manager-dialog'
type WorkspaceNavigationProps = {
children?: ReactNode
footer?: ReactNode
hasTemporaryDecks?: boolean
}
export function WorkspaceNavigation({
children,
footer,
hasTemporaryDecks,
}: WorkspaceNavigationProps) {
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [
{
title: 'リーダー',
to: '/inbox',
icon: Inbox,
active: pathname === '/inbox',
},
{
title: 'Twitter Lite',
to: '/deck',
icon: Search,
active: pathname === '/' || pathname === '/deck',
},
] as const
return (
<Sidebar
collapsible="none"
className="workspace-navigation w-16! shrink-0 border-r"
>
<SidebarHeader className="items-center py-4">
<div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
role="img"
aria-label="ワークスペース"
>
<Layers3 className="size-5" aria-hidden="true" />
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="px-3">
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
aria-label={item.title}
aria-current={item.active ? 'page' : undefined}
tooltip={{ children: item.title, hidden: false }}
isActive={item.active}
render={<Link to={item.to} />}
className="size-10 justify-center p-0"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{children && (
<>
<SidebarSeparator className="mx-3 w-auto" />
<SidebarGroup className="px-3">
<SidebarGroupContent>
<SidebarMenu>{children}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)}
</SidebarContent>
<SidebarFooter className="items-center px-3 pb-4">
{footer && <SidebarMenu>{footer}</SidebarMenu>}
<SidebarMenu className="mt-2">
<SidebarMenuItem>
<SidebarMenuButton
aria-label="接続アカウントを管理"
tooltip={{ children: '接続アカウントを管理', hidden: false }}
className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)}
>
<Avatar className="size-8">
<AvatarFallback>Y</AvatarFallback>
</Avatar>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{managingAccounts && (
<ConnectionManagerDialog
onClose={() => setManagingAccounts(false)}
hasTemporaryDecks={hasTemporaryDecks}
/>
)}
</Sidebar>
)
}
@@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions'
export function ConnectionManagerDialog({
onClose,
hasTemporaryDecks,
}: {
onClose: () => void
hasTemporaryDecks?: boolean
}) {
const fetchConnections = useServerFn(loadConnections)
const connections = useQuery({
queryKey: ['connections'],
queryFn: () => fetchConnections(),
retry: false,
})
return (
<Dialog title="接続アカウント" onClose={onClose} className="sm:max-w-2xl">
{connections.isPending ? (
<p role="status">接続アカウントを読み込んでいます…</p>
) : connections.isError ? (
<div className="grid gap-3">
<p role="alert">接続アカウントを取得できませんでした。</p>
<Button variant="outline" onClick={() => void connections.refetch()}>
再試行
</Button>
</div>
) : (
<>
{connections.data.relayError && (
<p role="alert">{connections.data.relayError}</p>
)}
<ConnectionManager
connections={connections.data.connections}
onChanged={() => connections.refetch()}
hasTemporaryDecks={hasTemporaryDecks}
/>
</>
)}
</Dialog>
)
}
+1 -24
View File
@@ -15,7 +15,6 @@ import {
EmptyTitle, EmptyTitle,
} from '#/components/ui/empty' } from '#/components/ui/empty'
import { Input } from '#/components/ui/input' import { Input } from '#/components/ui/input'
import { ConnectionManager } from '#/features/connections/connection-manager'
import { loadConnections } from '#/features/connections/server-functions' import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync' import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel' import { ResearchPanel } from '#/features/research/research-panel'
@@ -41,7 +40,6 @@ export function DeckPage() {
const researchDeckBindings = useRef(new Map<string, string>()).current const researchDeckBindings = useRef(new Map<string, string>()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null) const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = useState(false) const [renaming, setRenaming] = useState(false)
const [managingConnections, setManagingConnections] = useState(false)
const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null) const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
const [newDeckTitle, setNewDeckTitle] = useState('') const [newDeckTitle, setNewDeckTitle] = useState('')
const switcherEditing = deckDialog !== null const switcherEditing = deckDialog !== null
@@ -155,6 +153,7 @@ export function DeckPage() {
> >
{(renderResearchPanel) => ( {(renderResearchPanel) => (
<AppShell <AppShell
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
researchChat={renderResearchPanel} researchChat={renderResearchPanel}
sidebar={ sidebar={
<> <>
@@ -204,14 +203,6 @@ export function DeckPage() {
? 'サーバーに保存・端末間で共有' ? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'} : '一時ビュー · このタブのみ'}
</p> </p>
<Button
variant="outline"
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</Button>
</> </>
} }
> >
@@ -308,20 +299,6 @@ export function DeckPage() {
{profiles.data.relayError} {profiles.data.relayError}
</p> </p>
) : null} ) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some(
(deck) => !deck.persisted,
)}
/>
</Dialog>
) : null}
{profiles.isPending ? ( {profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p> <p role="status">接続プロファイルを取得しています…</p>
) : null} ) : null}
+146
View File
@@ -0,0 +1,146 @@
import { ArrowLeft, Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarProvider,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { type Article, articles } from './mock-articles'
import './inbox.css'
export function InboxPage() {
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>(
articles[0]?.id,
)
const [readingMobile, setReadingMobile] = useState(false)
const visible = articles.filter((article) =>
`${article.title} ${article.summary} ${article.source}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
)
const selected =
visible.find((article) => article.id === selectedId) ?? visible[0]
return (
<TooltipProvider delay={500}>
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}>
<Sidebar collapsible="none" className="inbox-sidebar">
<WorkspaceNavigation />
<Sidebar collapsible="none" className="inbox-list-panel">
<SidebarHeader className="gap-4 border-b p-5">
<h1 className="text-base font-semibold">リーダー</h1>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground"
/>
<Input
aria-label="記事を検索"
placeholder="検索"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</SidebarHeader>
<SidebarContent className="gap-0" aria-label="記事一覧">
{visible.map((article) => (
<ArticleRow
key={article.id}
article={article}
selected={selected?.id === article.id}
onSelect={() => {
setSelectedId(article.id)
setReadingMobile(true)
}}
/>
))}
{visible.length === 0 && (
<div className="inbox-no-results">
<p>記事がありません</p>
<Button variant="outline" onClick={() => setQuery('')}>
検索をクリア
</Button>
</div>
)}
</SidebarContent>
</Sidebar>
</Sidebar>
<SidebarInset className="inbox-reader">
<header className="inbox-reader-toolbar">
<Button
variant="ghost"
size="icon"
className="inbox-back"
aria-label="記事一覧に戻る"
onClick={() => setReadingMobile(false)}
>
<ArrowLeft />
</Button>
<span className="text-xs text-muted-foreground">
{selected?.source}
</span>
</header>
<section
className="inbox-reader-scroll"
key={selected?.id}
aria-label="記事本文"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
tabIndex={0}
>
{selected && <ArticleReader article={selected} />}
</section>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
function ArticleRow({
article,
selected,
onSelect,
}: {
article: Article
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
className="inbox-article-row"
aria-current={selected ? 'true' : undefined}
onClick={onSelect}
>
<span className="inbox-row-meta">
<span className="truncate">{article.source}</span>
<time className="ml-auto shrink-0">{article.date}</time>
</span>
<span className="inbox-row-title">{article.title}</span>
<span className="inbox-row-summary">{article.summary}</span>
</button>
)
}
function ArticleReader({ article }: { article: Article }) {
return (
<article className="inbox-article" aria-label={article.title}>
<h2 className="inbox-article-title">{article.title}</h2>
<p className="inbox-byline text-xs text-muted-foreground">
{article.author} · {article.date}
</p>
<div className="inbox-article-body">
{article.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</article>
)
}
+148
View File
@@ -0,0 +1,148 @@
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.inbox-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.inbox-sidebar {
width: 420px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.inbox-list-panel {
flex: 1;
min-width: 0;
width: auto;
}
.inbox-article-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 11px;
width: 100%;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
}
.inbox-article-row:hover {
background: var(--sidebar-accent);
}
.inbox-article-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.inbox-row-meta {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--muted-foreground);
font-size: 11px;
}
.inbox-row-title {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.65;
text-wrap: pretty;
}
.inbox-row-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.8;
}
.inbox-reader {
min-width: 0;
height: 100%;
overflow: hidden;
}
.inbox-reader-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 10px 26px;
border-bottom: 1px solid var(--border);
}
.inbox-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.inbox-article {
max-width: 800px;
margin: 0 auto;
padding: 54px 52px 40px;
}
.inbox-article-title {
margin: 0;
font-size: clamp(24px, 2.4vw, 34px);
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.inbox-byline {
margin-block: 30px;
}
.inbox-article-body {
font-size: 15px;
line-height: 2.15;
letter-spacing: 0.015em;
}
.inbox-article-body p + p {
margin-top: 24px;
}
.inbox-no-results {
display: grid;
justify-items: start;
gap: 12px;
padding: 32px 20px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.8;
}
.inbox-back {
display: none;
}
@media (min-width: 768px) and (max-width: 1100px) {
.inbox-sidebar {
width: 340px;
}
.inbox-article {
padding: 36px 28px;
}
}
@media (max-width: 767px) {
.inbox-sidebar {
width: 100%;
border-right: 0;
}
.inbox-reader {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-sidebar {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-reader {
display: flex;
}
.inbox-back {
display: inline-flex;
}
.inbox-reader-toolbar {
padding-inline: 12px;
}
.inbox-article {
padding: 30px 24px;
}
}
+138
View File
@@ -0,0 +1,138 @@
export type Article = {
id: string
title: string
summary: string
source: string
author: string
date: string
paragraphs: string[]
}
// All articles are original mock content. Editorial names are fictional.
export const articles: Article[] = [
{
id: 'agent-browser-workflow',
title: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
summary:
'コードを書いて終わりにしない。画面を開き、操作し、確かめるところまでをひとつの作業にする。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '09:40',
paragraphs: [
'UIをつくるとき、コードが正しいことと、画面が使いやすいことの間には距離がある。ボタンが画面の外に出ていないか。入力した文章は画面を切り替えても残るか。こうした問いへの答えは、実際にブラウザを動かして初めて見えてくる。',
'そこで試したいのが、実装と確認をひとつの短い循環にする進め方だ。エージェントに変更を依頼するとき、「記事を選び、詳細を読み、一覧に戻る」といった利用者の動作も添える。作業の終点がファイルの変更から、動作の確認へと具体的になる。',
'確認には、ふだんの画面幅と狭い画面幅の両方を使う。うまく動いた結果だけでなく、何を操作し、何を確認できなかったかも残しておくと、次の変更で同じ箇所を確かめやすい。最初から網羅するより、重要な操作をひとつ確実に通すことから始めたい。',
'人が担うのは、その体験が目的に合っているかを判断すること。操作の繰り返しをエージェントに任せると、余白や言葉、情報の順序といった、使い心地に向き合う時間を取りやすくなる。',
],
},
{
id: 'small-cli-tools',
title: '毎日使いたくなるCLIは、終了時のひと言まで設計されている',
summary:
'ヘルプ、エラー、実行結果。小さな道具の使い心地は、出力の細部で変わる。',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '09:15',
paragraphs: [
'コマンドを実行したあと、何が起きたのか分からない。これは処理そのものが成功していても、使う側には不安が残る体験だ。作成したファイルの場所や処理件数を一行で伝えるだけで、道具とのやり取りはずっと明快になる。',
'エラーも、原因と次の行動が近くにあると扱いやすい。「設定が不正です」より、「出力先が見つかりません。存在するディレクトリを指定してください」のほうが、調べ直す手間を減らせる。内部の事情は必要なときにだけ表示できればよい。',
'人が読む出力と、別のプログラムが読む出力の用途も分けて考えたい。通常は簡潔な文章を返し、構造化された結果が必要なら専用の形式を選べるようにする。小さなCLIほど、こうした一貫性が長く使う理由になる。',
],
},
{
id: 'three-column-reading',
title: '3カラムの余白が、情報を読むリズムをつくる',
summary:
'場所を選ぶ、候補を眺める、ひとつを読む。役割が分かれた画面について。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '08:50',
paragraphs: [
'記事を探すとき、私たちはすべての文章を同じ深さで読んでいるわけではない。関心のある分野を選び、見出しを流し読みし、気になるものにだけ腰を据える。3カラムの画面は、この異なる読み方にそれぞれの場所を用意できる。',
'左は現在地、中央は候補、右は本文。役割が明確なら、記事を開いても一覧の中での位置を見失いにくい。選択中の項目を穏やかに示すことや、一覧のスクロール位置を保つことが、この安心感を支えている。',
'すべてのカラムを等しい幅にする必要はない。見出しを判断できる一覧の幅を確保し、残りを本文に渡す。本文にも適切な最大幅を設ければ、大きな画面で一行が長くなりすぎることを防げる。',
'狭い画面では、同じ役割を順番に見せればよい。一覧と本文を切り替えても、戻る場所が明確であること。カラムの数よりも、読む途中の文脈が続いていることが大切だ。',
],
},
{
id: 'agent-context',
title: 'エージェントに渡す文脈は、短くてもよい',
summary:
'大量の説明よりも、目的・制約・完了条件をそろえる。依頼を整理する小さな習慣。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '08:30',
paragraphs: [
'依頼を詳しく書こうとするほど、背景と希望と必須条件が混ざってしまうことがある。受け手がエージェントでも人でも、最初に知りたいのは何を実現する作業なのかという一点だ。',
'まず目的を一文で書き、次に変えてはいけない条件を添える。最後に、何を確かめたら作業が終わるのかを決める。参考ファイルやスクリーンショットは、その三つを補うために渡すと役割がはっきりする。',
'途中で判断が変わったときは、説明を足し続けるより、現在の方針を短く書き直すほうが役立つ場合がある。文脈の量ではなく、いま有効な判断を見つけやすいことを意識したい。',
],
},
{
id: 'local-first-notes',
title: 'ローカルで完結する、小さな情報収集ワークスペース',
summary:
'集めることより、あとから見つけられること。ファイルを中心にした道具の組み合わせ。',
source: 'Workbench Journal',
author: 'Workbench Journal 編集部',
date: '昨日',
paragraphs: [
'気になった記事を保存する場所は増えがちだ。ブラウザのブックマーク、メモアプリ、チャットの自分宛てメッセージ。集める入口が便利でも、読み返す入口が分かれていると、保存したこと自体を忘れてしまう。',
'小さく始めるなら、タイトル、出典、保存した理由だけをテキストで残す方法がある。本文をすべて保管する前に、なぜ自分が気になったのかを一行添えておく。その一行が、検索したときの判断材料になる。',
'ローカルのファイルを中心にすると、使い慣れた検索やエディタと組み合わせやすい。一方、複数の端末で扱うなら同期や競合の扱いを決める必要がある。道具を増やす前に、どこで読み、いつ振り返るのかを決めておきたい。',
],
},
{
id: 'quiet-design-system',
title: 'デザインシステムを、使う人の言葉で整える',
summary: 'コンポーネントをそろえた先にある、ラベルや空状態の一貫性。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '昨日',
paragraphs: [
'ボタンの角丸や色がそろっていても、同じ操作を画面ごとに違う言葉で呼んでいると迷いが生まれる。「保存」と「追加」が何を意味するのか。見た目と同じように、言葉にも共通のルールが必要だ。',
'最初に整理したいのは、よく使う操作と状態の名前だ。未読、保存済み、読み込み中などの基本的な表現を決めておくと、新しい画面をつくるときの判断が少なくなる。専門用語を画面に出す前に、使う人がその違いを知る必要があるかも考えたい。',
'空の一覧にも役割がある。何もない理由と、次にできることが簡潔に分かれば、空状態は行き止まりにならない。派手なイラストがなくても、適切な一文と操作があれば十分なことは多い。',
],
},
{
id: 'personal-feed',
title: 'おすすめの理由が見える、パーソナルな記事フィード',
summary:
'「なぜこの記事?」に答えられることが、興味に基づく推薦の出発点になる。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '昨日',
paragraphs: [
'興味に合う記事が届くと便利だが、その理由が分からないと、偶然なのか自分の行動が反映されたのか判断しづらい。推薦の根拠を短く添えるだけで、読む側は自分との接点を見つけやすくなる。',
'理由は複雑なスコアである必要はない。「選んだトピックに関連」「保存した記事と近いテーマ」など、実際に使っている情報と対応していることが大切だ。まだ推薦処理がない試作なら、その理由もサンプルであることを明示しておく。',
'関心は変化するので、利用者が調整できる入口も必要になる。トピックの追加や解除、読みたくない記事の非表示など、小さな操作から始められる。よいフィードは、当て続ける仕組みだけでなく、外れたときに直せる仕組みでもある。',
],
},
{
id: 'behavior-tests',
title: 'テストに残したいのは、実装よりも利用者の約束',
summary: '内部の構造を変えても守りたい動作を、短いシナリオで表す。',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '2日前',
paragraphs: [
'コンポーネントを分割しただけなのに、多くのテストが壊れる。そんなときは、テストが利用者の操作よりも内部の構造に寄り添っていないかを見直したい。守りたいのは、どの関数が呼ばれたかではなく、期待する結果が得られたかという約束だ。',
'記事一覧なら、「トピックを選ぶと該当する記事が並ぶ」「記事を保存すると保存済みで見つけられる」といったシナリオを考えられる。画面に表示される名前で操作して、結果も画面から確かめると、何を保証するテストかが読み取りやすい。',
'もちろん、すべてをブラウザで確かめる必要はない。複雑な絞り込みの計算は小さな単位で検証し、代表的な操作の流れを画面で確認する。変更の影響に合わせて確認の範囲を選ぶことが、続けやすいテストにつながる。',
],
},
{
id: 'reading-typography',
title: '読みやすい本文は、文字サイズだけでは決まらない',
summary: '行の長さ、行間、段落の間隔。読み進めやすい記事画面のためのメモ。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '2日前',
paragraphs: [
'文字が十分に大きくても、一行が長すぎると次の行を見つけるのが難しくなる。逆に幅が狭すぎると、短い単語のまとまりまで細かく折り返され、読むリズムが途切れる。本文の幅は、文字サイズと一緒に考えたい。',
'行間は一行ごとの読みやすさを、段落の間隔は話のまとまりを支えている。同じ余白として扱わず、それぞれの役割が見えるように調整すると、装飾を増やさなくても文章の構造が伝わる。',
'調整するときは、短いダミーテキストだけで判断しない。長い見出しや複数段落の記事を置き、実際に数分読んでみる。画面の見栄えだけでは気づけない、読み疲れや視線の迷いを見つけやすくなる。',
],
},
]
+31 -2
View File
@@ -9,11 +9,17 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as DeckRouteImport } from './routes/deck' import { Route as DeckRouteImport } from './routes/deck'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback' import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events' import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
const InboxRoute = InboxRouteImport.update({
id: '/inbox',
path: '/inbox',
getParentRoute: () => rootRouteImport,
} as any)
const DeckRoute = DeckRouteImport.update({ const DeckRoute = DeckRouteImport.update({
id: '/deck', id: '/deck',
path: '/deck', path: '/deck',
@@ -38,12 +44,14 @@ const ApiResearchEventsRoute = ApiResearchEventsRouteImport.update({
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/deck': typeof DeckRoute '/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/deck': typeof DeckRoute '/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
@@ -51,18 +59,30 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/deck': typeof DeckRoute '/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/api/research/events': typeof ApiResearchEventsRoute '/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute '/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/deck' | '/api/research/events' | '/oauth/mastodon/callback' fullPaths:
| '/'
| '/deck'
| '/inbox'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/deck' | '/api/research/events' | '/oauth/mastodon/callback' to:
| '/'
| '/deck'
| '/inbox'
| '/api/research/events'
| '/oauth/mastodon/callback'
id: id:
| '__root__' | '__root__'
| '/' | '/'
| '/deck' | '/deck'
| '/inbox'
| '/api/research/events' | '/api/research/events'
| '/oauth/mastodon/callback' | '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
@@ -70,12 +90,20 @@ export interface FileRouteTypes {
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
DeckRoute: typeof DeckRoute DeckRoute: typeof DeckRoute
InboxRoute: typeof InboxRoute
ApiResearchEventsRoute: typeof ApiResearchEventsRoute ApiResearchEventsRoute: typeof ApiResearchEventsRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/inbox': {
id: '/inbox'
path: '/inbox'
fullPath: '/inbox'
preLoaderRoute: typeof InboxRouteImport
parentRoute: typeof rootRouteImport
}
'/deck': { '/deck': {
id: '/deck' id: '/deck'
path: '/deck' path: '/deck'
@@ -110,6 +138,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
DeckRoute: DeckRoute, DeckRoute: DeckRoute,
InboxRoute: InboxRoute,
ApiResearchEventsRoute: ApiResearchEventsRoute, ApiResearchEventsRoute: ApiResearchEventsRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute, OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
} }
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { InboxPage } from '#/features/inbox/inbox-page'
export const Route = createFileRoute('/inbox')({ component: InboxPage })