feat: migrate workspace to shadcn sidebar and chat components

This commit is contained in:
2026-09-28 17:29:08 +09:00
parent 2346848839
commit 96e545401e
61 changed files with 6727 additions and 1146 deletions
+8
View File
@@ -1,4 +1,12 @@
[ [
{
"date": "2026-09-28",
"macrostructure": "sidebar-09 Workbench",
"theme": "shadcn base-nova neutral",
"scope": "app",
"enrichment": "none",
"brief": "Base UI icon rail and contextual sidebar with shadcn chat primitives and independent deck columns"
},
{ {
"date": "2026-09-24", "date": "2026-09-24",
"macrostructure": "Workbench", "macrostructure": "Workbench",
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"categories": { "correctness": "off" },
"jsPlugins": ["@shadcn/lint"],
"rules": {
"shadcn/no-raw-colors": "error"
},
"ignorePatterns": ["src/routeTree.gen.ts"]
}
+28 -5
View File
@@ -66,6 +66,9 @@ profiles; Mastodon credentials never go to the browser.
nix develop -c pnpm dev nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale nix develop -c pnpm dev:tailscale
nix develop -c pnpm generate:e2e-openapi nix develop -c pnpm generate:e2e-openapi
nix develop -c pnpm lint
nix develop -c pnpm lint:ui
nix develop -c pnpm typecheck
nix develop -c pnpm knip nix develop -c pnpm knip
nix develop -c pnpm test nix develop -c pnpm test
nix develop -c pnpm test:e2e nix develop -c pnpm test:e2e
@@ -163,11 +166,31 @@ and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
## Deck workspace ## Deck workspace
The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar, The interface uses shadcn/ui's Base UI components and Tailwind CSS v4, with a
compact column headers, and independently scrolling post columns. On narrow collapsible sidebar based on
screens, the sidebar becomes a compact top bar. Add/edit forms open in native [sidebar-09](https://github.com/shadcn-ui/ui/tree/98a1fe67b439324ddc857f47fbdce056600a4329/apps/v4/registry/bases/base/blocks/sidebar-09).
dialogs; Escape closes them and returns focus. Column menus contain editing, The workspace retains compact column headers and independently scrolling post
ordering, and deletion; refresh stays available directly in each header. columns. On narrow screens, the sidebar opens as a sheet. Add/edit forms use
shadcn dialogs with Escape handling and focus restoration. Column menus contain
editing, ordering, and deletion; refresh stays available directly in each header.
Shared UI components live in `src/components/ui`; `components.json` configures
the registry and theme entrypoint. Chat uses MessageScroller, Message, Bubble,
Marker and InputGroup while retaining the existing Codex transport and saved
conversation model. The icon rail switches between chat and deck management;
closing the mobile sheet or crossing the desktop breakpoint preserves chat
drafts, selected accounts and live deck updates. Theme colors are declared in
`src/ui.css`.
`pnpm lint` runs Biome and the UI checks. `pnpm lint:ui` runs
[@shadcn/lint](https://github.com/shadcn-ui/lint) through Oxlint. The
`shadcn/no-raw-colors` rule in `.oxlintrc.json` requires theme colors in JSX and
class helpers, including shared UI components. Layout values remain available
for the sidebar and deck. Biome continues to handle formatting and general
code checks. Knip checks application usage while retaining the public exports
of registry components for composition. Additional design rules can be configured
in `.oxlintrc.json`;
see the [available rules](https://github.com/shadcn-ui/lint#rules).
Create a deck for an investigation, then add columns for each perspective. Create a deck for an investigation, then add columns for each perspective.
Choose a connected account and one of its supported sources. Account Choose a connected account and one of its supported sources. Account
+19 -3
View File
@@ -8,9 +8,25 @@
"!!**/test-results" "!!**/test-results"
] ]
}, },
"formatter": { "enabled": true, "indentStyle": "space" }, "formatter": {
"linter": { "enabled": true, "rules": { "recommended": true } }, "enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": { "javascript": {
"formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } "formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
} }
} }
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/ui.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"aliases": {
"components": "#/components",
"utils": "#/lib/utils",
"ui": "#/components/ui",
"lib": "#/lib",
"hooks": "#/hooks"
}
}
+54
View File
@@ -0,0 +1,54 @@
# shadcn workspace migration
## Target
Replace the custom workspace UI with the Base UI version of shadcn sidebar-09
at commit 98a1fe67b439324ddc857f47fbdce056600a4329. Preserve its icon rail,
contextual second sidebar and SidebarInset. Adapt the rail to research chat and
deck management, keeping the research panel mounted so SSE state remains live.
On small screens, use the sidebar Sheet and keep all actions reachable.
Use official MessageScroller, Message, Bubble and Marker for the existing Codex
conversation. Keep transport, history, retry, stop, account selection, Markdown
and citation navigation. Use shadcn primitives for forms, dialogs, menus and
post metadata throughout the app. Use Tailwind v4 and shared shadcn semantic
tokens; remove legacy control styles that override primitives.
Install @shadcn/lint with an executable design-token policy alongside Biome.
Update README and Nix dependency hash as needed.
## Verification
- Typecheck, Biome, design lint, unit suite, production build, unused-code check.
- Browser integration suite: decks, source editing, connections, conversation,
citation navigation, menus and keyboard focus.
- Render desktop plus 320/375/414/768 px: rail collapse, mobile Sheet, independent
column scrolling, chat composer visibility, no document horizontal overflow.
- Inspect screenshots and run Axe on relevant states.
## Status
Completed on 2026-09-28.
- SidebarProvider, nested icon/context Sidebars and SidebarInset now implement
the referenced sidebar-09 structure; the mobile Sheet keeps its content mounted.
- Research state and deck-dialog drafts live above the responsive shell. Closing
or resizing the sidebar preserves drafts, selected accounts and retry identity.
- MessageScroller, Message, Bubble/BubbleContent, Marker/MarkerContent, InputGroup
and Collapsible replace the chat UI. Markdown list markers and headings remain
visible after Tailwind's reset; citation highlighting uses theme tokens.
- Forms, dialogs, dropdown menus, avatars, badges and empty states use Base UI
shadcn components. No backend transport, storage or access contracts changed.
- @shadcn/lint runs in pnpm lint and rejects raw palette colors. Nix dependency
hash and README/deck docs are updated.
Verification: 336 unit tests passed (one explicit live-relay test skipped), all
56 desktop/mobile E2E tests passed. After the final isolated chat styling fixes,
all 10 research E2E tests and 22 related unit tests passed again. Typecheck,
Biome/design lint, Knip, production build and the Nix package build passed.
Browser checks covered 320/375/414/768px, rendered chat and post screenshots,
Axe, dialog focus restoration, breakpoint draft preservation and closed-sidebar
SSE deck updates. Automated browser tests use mock relay/research data.
A subsequent dev-server smoke check through the Tailscale HTTPS URL confirmed
the real relay displayed 14 posts across six columns without UI alerts.
No production deployment was performed.
+8 -6
View File
@@ -2,12 +2,14 @@
## Workspace ## Workspace
Both `/` and `/deck` render the deck-only application. A dark sidebar switches Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui
decks, adds columns, and jumps to a column. Up to six ordered columns scroll sidebar based on `sidebar-09` switches between research chat and deck management.
independently; only the active deck mounts and fetches its columns. On mobile, Deck management switches decks, adds columns, and manages connected accounts. Up to six ordered columns scroll independently; only the active deck
the sidebar becomes a compact top bar. Native dialogs handle creation and mounts and fetches its columns. On mobile, the sidebar opens as a sheet.
editing, with Escape and focus restoration. Column menus offer editing, shadcn Base UI dialogs handle creation and editing, with Escape and focus
ordering, and deletion; refresh and pagination are manual. restoration. Column menus offer editing, ordering, and deletion; refresh and
pagination are manual. The adjacent chat uses MessageScroller, Message, Bubble,
Marker and InputGroup with the existing research backend.
Twitter and Mastodon can appear together, with different accounts in each Twitter and Mastodon can appear together, with different accounts in each
column. Original-post links open their source site. Separate reader, search, column. Original-post links open their source site. Separate reader, search,
+3
View File
@@ -1,9 +1,11 @@
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)
const manage = page.getByRole('button', { name: '接続アカウントを管理' }) const manage = page.getByRole('button', { name: '接続アカウントを管理' })
await expect(manage).toBeEnabled() await expect(manage).toBeEnabled()
await manage.click() await manage.click()
@@ -28,6 +30,7 @@ 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( await expect(
page.getByRole('button', { name: '接続アカウントを管理' }), page.getByRole('button', { name: '接続アカウントを管理' }),
).toBeEnabled() ).toBeEnabled()
+57 -9
View File
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test' import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures' import { expect, test } from '../fixtures'
import { closeMobileSidebar, showDeckManagement } from '../workspace'
async function addColumn( async function addColumn(
page: Page, page: Page,
@@ -8,6 +9,7 @@ async function addColumn(
profile = 'e2e', profile = 'e2e',
kind = 'search', kind = 'search',
) { ) {
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true }) const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
await editor.getByLabel('カラム名', { exact: true }).fill(title) await editor.getByLabel('カラム名', { exact: true }).fill(title)
@@ -28,6 +30,7 @@ async function addColumn(
) )
.fill(query) .fill(query)
await editor.getByRole('button', { name: '追加して検索' }).click() await editor.getByRole('button', { name: '追加して検索' }).click()
await closeMobileSidebar(page)
await expect( await expect(
page page
.getByRole('region', { name: title, exact: true }) .getByRole('region', { name: title, exact: true })
@@ -44,7 +47,9 @@ test.beforeEach(async ({ page }) => {
await page await page
.getByRole('button', { name: 'デッキとして保存', exact: true }) .getByRole('button', { name: 'デッキとして保存', exact: true })
.click() .click()
await expect(page.getByText('サーバーに保存・端末間で共有')).toHaveCount(1) await showDeckManagement(page)
await expect(page.getByText('サーバーに保存・端末間で共有')).toBeVisible()
await closeMobileSidebar(page)
}) })
test('binds identical searches to independent profiles through paging, editing and reload', async ({ test('binds identical searches to independent profiles through paging, editing and reload', async ({
@@ -67,8 +72,8 @@ test('binds identical searches to independent profiles through paging, editing a
await expect( await expect(
second.getByText('alternate · Latest · all page 2'), second.getByText('alternate · Latest · all page 2'),
).toBeVisible() ).toBeVisible()
await second.locator('summary[aria-label="別の接続の操作"]').click() await second.getByRole('button', { name: '別の接続の操作' }).click()
await second.getByRole('button', { name: '編集', exact: true }).click() await page.getByRole('menuitem', { name: '編集', exact: true }).click()
await page await page
.getByRole('combobox', { name: '接続プロファイル', exact: true }) .getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption({ label: 'e2e' }) .selectOption({ label: 'e2e' })
@@ -93,25 +98,32 @@ test('creates, switches, renames and deletes persisted deck profiles', async ({
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査') await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
await page.getByRole('button', { name: '名前を保存' }).click() await page.getByRole('button', { name: '名前を保存' }).click()
await addColumn(page, '最初の観点', 'first-deck') await addColumn(page, '最初の観点', 'first-deck')
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click() await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('別の調査') await page.getByLabel('新しいデッキ名').fill('別の調査')
await page.getByRole('button', { name: '作成', exact: true }).click() await page.getByRole('button', { name: '作成', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0) await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate') await addColumn(page, '別の観点', 'second-deck', 'alternate')
await showDeckManagement(page)
await page await page
.getByLabel('デッキプロファイル', { exact: true }) .getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '最初の調査' }) .selectOption({ label: '最初の調査' })
await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
await page.reload() await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await page await page
.getByLabel('デッキプロファイル', { exact: true }) .getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '別の調査' }) .selectOption({ label: '別の調査' })
await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['別の観点']) await expect(page.locator('.deck-column h2')).toHaveText(['別の観点'])
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを削除', exact: true }).click() await page.getByRole('button', { name: 'デッキを削除', exact: true }).click()
await page.getByRole('button', { name: '削除する', exact: true }).click() await page.getByRole('button', { name: '削除する', exact: true }).click()
await page.reload() await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査') await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await expect( await expect(
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'), page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
).toHaveCount(1) ).toHaveCount(1)
@@ -128,14 +140,14 @@ test('reads user and list timelines within columns and reorders, deletes and res
await expect(list.getByText('list page 1')).toBeVisible() await expect(list.getByText('list page 1')).toBeVisible()
await list.getByRole('button', { name: '続きを読み込む' }).click() await list.getByRole('button', { name: '続きを読み込む' }).click()
await expect(list.getByText('list page 2')).toBeVisible() await expect(list.getByText('list page 2')).toBeVisible()
await list.locator('summary[aria-label="リストの投稿の操作"]').click() await list.getByRole('button', { name: 'リストの投稿の操作' }).click()
await list.getByRole('button', { name: 'リストの投稿を左へ' }).click() await page.getByRole('menuitem', { name: 'リストの投稿を左へ' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([ await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿', 'リストの投稿',
'アカウント', 'アカウント',
]) ])
await list.locator('summary[aria-label="リストの投稿の操作"]').click() await list.getByRole('button', { name: 'リストの投稿の操作' }).click()
await list.getByRole('button', { name: 'リストの投稿を削除' }).click() await page.getByRole('menuitem', { name: 'リストの投稿を削除' }).click()
await page.getByRole('button', { name: '元に戻す' }).click() await page.getByRole('button', { name: '元に戻す' }).click()
await expect(page.locator('.deck-column h2')).toHaveText([ await expect(page.locator('.deck-column h2')).toHaveText([
'リストの投稿', 'リストの投稿',
@@ -200,6 +212,7 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
path: testInfo.outputPath('deck-populated.png'), path: testInfo.outputPath('deck-populated.png'),
fullPage: true, fullPage: true,
}) })
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
expect((await a11y().analyze()).violations).toEqual([]) expect((await a11y().analyze()).violations).toEqual([])
await page.screenshot({ await page.screenshot({
@@ -211,12 +224,17 @@ test('keeps the deck and editor accessible within narrow viewports', async ({
test('closes the column editor with Escape and restores focus without applying edits', async ({ test('closes the column editor with Escape and restores focus without applying edits', async ({
page, page,
}) => { }) => {
await showDeckManagement(page)
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true }) const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
await opener.click() await opener.click()
await expect(page.getByRole('dialog')).toBeVisible() await expect(
page.getByRole('dialog', { name: 'カラムを追加', exact: true }),
).toBeVisible()
await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点') await page.getByLabel('カラム名', { exact: true }).fill('キャンセルする観点')
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0) await expect(
page.getByRole('dialog', { name: 'カラムを追加', exact: true }),
).toHaveCount(0)
await expect(opener).toBeFocused() await expect(opener).toBeFocused()
await expect(page.locator('.deck-column')).toHaveCount(0) await expect(page.locator('.deck-column')).toHaveCount(0)
await opener.click() await opener.click()
@@ -239,6 +257,7 @@ test('does not overwrite unreadable storage on reload', async ({ page }) => {
test('selects a list from the column profile catalog and refreshes it on profile change', async ({ test('selects a list from the column profile catalog and refreshes it on profile change', async ({
page, page,
}) => { }) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト') await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page await page
@@ -263,6 +282,7 @@ test('selects a list from the column profile catalog and refreshes it on profile
'1234567890', '1234567890',
) )
await page.getByRole('button', { name: '追加して検索' }).click() await page.getByRole('button', { name: '追加して検索' }).click()
await closeMobileSidebar(page)
await expect( await expect(
page page
.getByRole('region', { name: '購読リスト', exact: true }) .getByRole('region', { name: '購読リスト', exact: true })
@@ -347,11 +367,15 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
legacy, legacy,
) )
await page.reload() await page.reload()
await showDeckManagement(page)
const selector = page.getByLabel('デッキプロファイル', { exact: true }) const selector = page.getByLabel('デッキプロファイル', { exact: true })
await expect(selector.locator('option')).toHaveCount(1) await expect(selector.locator('option')).toHaveCount(1)
await closeMobileSidebar(page)
await page.getByRole('button', { name: '旧デッキを取り込む' }).click() await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2) await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' }) await selector.selectOption({ label: '以前の調査' })
await closeMobileSidebar(page)
await expect( await expect(
page.getByText('Latest · all page 1', { exact: true }), page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible() ).toBeVisible()
@@ -368,5 +392,29 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
await expect( await expect(
page.getByRole('button', { name: '旧デッキを取り込む' }), page.getByRole('button', { name: '旧デッキを取り込む' }),
).toHaveCount(0) ).toHaveCount(0)
await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2) await expect(selector.locator('option')).toHaveCount(2)
}) })
test('preserves a new deck dialog and its draft across the mobile breakpoint', async ({
page,
}) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'デッキを作成', exact: true })
await dialog.getByLabel('新しいデッキ名').fill('画面を回転しても残る調査')
for (const width of [1024, 375, 1024]) {
await page.setViewportSize({ width, height: 900 })
await expect(dialog).toHaveCount(1)
await expect(dialog).toBeVisible()
await expect(dialog.getByLabel('新しいデッキ名')).toHaveValue(
'画面を回転しても残る調査',
)
await expect(page.locator('#new-deck-title')).toHaveCount(1)
}
await dialog.getByRole('button', { name: '作成', exact: true }).click()
await expect(dialog).toHaveCount(0)
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'画面を回転しても残る調査',
)
})
+85
View File
@@ -1,6 +1,11 @@
import type { DeckColumn } from '../../src/features/decks/model' import type { DeckColumn } from '../../src/features/decks/model'
import type { ResearchRun } from '../../src/features/research/model' import type { ResearchRun } from '../../src/features/research/model'
import { expect, test } from '../fixtures' import { expect, test } from '../fixtures'
import {
closeMobileSidebar,
showDeckManagement,
showResearch,
} from '../workspace'
test('pushes the current research snapshot on each SSE connection', async ({ test('pushes the current research snapshot on each SSE connection', async ({
page, page,
@@ -37,6 +42,7 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
page, page,
}) => { }) => {
await page.goto('/') await page.goto('/')
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', { const accounts = page.getByRole('combobox', {
name: '接続プロファイル', name: '接続プロファイル',
@@ -45,9 +51,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
await accounts.selectOption({ label: 'e2e' }) await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue() const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await closeMobileSidebar(page)
const run: ResearchRun = { const run: ResearchRun = {
id: '36ad8cc7-318c-46ae-94fa-28d30336f027', id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
threadId: 'conversation-test', threadId: 'conversation-test',
connectionIds: [connectionId],
topic: '既存の観点を使って調査して', topic: '既存の観点を使って調査して',
status: 'running', status: 'running',
startedAt: Date.now(), startedAt: Date.now(),
@@ -89,10 +97,14 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
}), }),
) )
await page.reload() await page.reload()
await showResearch(page)
const conversation = page.getByRole('log', { name: '調査の会話' }) const conversation = page.getByRole('log', { name: '調査の会話' })
await expect(conversation).toContainText('既存の観点を使って調査して') await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('list_decks: 完了') await expect(conversation).toContainText('list_decks: 完了')
await expect(conversation).toContainText('既存の検索条件を再利用しています。') await expect(conversation).toContainText('既存の検索条件を再利用しています。')
await expect(
conversation.locator('[data-slot="bubble-content"]'),
).not.toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点']) await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
run.deckVersion = 2 run.deckVersion = 2
run.status = 'complete' run.status = 'complete'
@@ -114,19 +126,79 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
}, },
}) })
await page.reload() await page.reload()
await showResearch(page)
await expect(conversation).toContainText('既存の観点を使って調査して') await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('比較する観点を追加しました。') await expect(conversation).toContainText('比較する観点を追加しました。')
await expect(page.locator('.deck-column h2')).toHaveText([ await expect(page.locator('.deck-column h2')).toHaveText([
'最初の観点', '最初の観点',
'比較の観点', '比較の観点',
]) ])
await closeMobileSidebar(page)
await expect( await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }), page.getByRole('button', { name: 'デッキとして保存', exact: true }),
).toBeEnabled() ).toBeEnabled()
await showResearch(page)
const accountPicker = page.getByRole('button', {
name: '接続アカウント · 1件',
})
await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'true')
await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).toBeChecked()
await accountPicker.click()
await expect(accountPicker).toHaveAttribute('aria-expanded', 'false')
await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく') await page.getByLabel('メッセージ', { exact: true }).fill('この観点を詳しく')
await expect( await expect(
page.getByRole('button', { name: '送信', exact: true }), page.getByRole('button', { name: '送信', exact: true }),
).toBeEnabled() ).toBeEnabled()
await page
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
.click()
run.deckVersion = 3
if (run.deck) run.deck.title = '閉じている間に更新された調査'
await expect(page.getByRole('heading', { level: 1 })).toHaveText(
'閉じている間に更新された調査',
{ timeout: 15000 },
)
await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.setViewportSize({ width: 600, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1)
await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.getByRole('button', { name: '接続アカウント · 1件' }).click()
await page.getByRole('checkbox', { name: 'e2e · Twitter' }).uncheck()
await page.setViewportSize({ width: 1100, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(0)
await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.getByRole('button', { name: '接続アカウント · 0件' }).click()
await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).not.toBeChecked()
await page.setViewportSize({ width: 600, height: 900 })
await expect(
page.locator('[data-sidebar="sidebar"][data-mobile="true"]'),
).toHaveCount(1)
await showResearch(page)
await expect(page.getByLabel('メッセージ', { exact: true })).toHaveValue(
'この観点を詳しく',
)
await page.getByRole('button', { name: '接続アカウント · 0件' }).click()
await expect(
page.getByRole('checkbox', { name: 'e2e · Twitter' }),
).not.toBeChecked()
}) })
test('rejects a different tailnet owner at the SSE endpoint', async ({ test('rejects a different tailnet owner at the SSE endpoint', async ({
@@ -144,6 +216,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
}) => { }) => {
await page.emulateMedia({ reducedMotion: 'reduce' }) await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/') await page.goto('/')
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click() await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', { const accounts = page.getByRole('combobox', {
name: '接続プロファイル', name: '接続プロファイル',
@@ -152,6 +225,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
await accounts.selectOption({ label: 'e2e' }) await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue() const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await closeMobileSidebar(page)
const source: DeckColumn = { const source: DeckColumn = {
id: 'citation-column-2', id: 'citation-column-2',
title: '引用の観点', title: '引用の観点',
@@ -213,8 +287,14 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
}), }),
) )
await page.reload() await page.reload()
await showResearch(page)
const chat = page.getByRole('log', { name: '調査の会話' }) const chat = page.getByRole('log', { name: '調査の会話' })
await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible() await expect(chat.getByRole('heading', { name: '調査結果' })).toBeVisible()
await expect(chat.getByRole('heading', { name: '調査結果' })).toHaveCSS(
'font-weight',
'600',
)
await expect(chat.locator('ul')).toHaveCSS('list-style-type', 'disc')
await expect( await expect(
chat.locator('strong').filter({ hasText: '重要な発見' }), chat.locator('strong').filter({ hasText: '重要な発見' }),
).toBeVisible() ).toBeVisible()
@@ -246,6 +326,7 @@ test('explains unconfigured Codex and prevents starting research without affecti
const heading = page.getByRole('heading', { level: 1 }) const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible() await expect(heading).toBeVisible()
const originalTitle = await heading.textContent() const originalTitle = await heading.textContent()
await showResearch(page)
const panel = page.getByRole('complementary', { name: '調査チャット' }) const panel = page.getByRole('complementary', { name: '調査チャット' })
await expect(panel).toBeVisible() await expect(panel).toBeVisible()
await expect( await expect(
@@ -257,10 +338,14 @@ test('explains unconfigured Codex and prevents starting research without affecti
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled() await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
const accessibility = await a11y().analyze() const accessibility = await a11y().analyze()
expect(accessibility.violations).toEqual([]) expect(accessibility.violations).toEqual([])
await closeMobileSidebar(page)
await expect(heading).toHaveText(originalTitle ?? '') await expect(heading).toHaveText(originalTitle ?? '')
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click() await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('手動の調査') await page.getByLabel('新しいデッキ名').fill('手動の調査')
await page.getByRole('button', { name: '作成', exact: true }).click() await page.getByRole('button', { name: '作成', exact: true }).click()
await closeMobileSidebar(page)
await expect(heading).toHaveText('手動の調査') await expect(heading).toHaveText('手動の調査')
await showResearch(page)
await expect(panel).toBeVisible() await expect(panel).toBeVisible()
}) })
+2 -5
View File
@@ -117,12 +117,9 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
).toBeVisible() ).toBeVisible()
await page await page
.getByRole('region', { name: '日本語', exact: true }) .getByRole('region', { name: '日本語', exact: true })
.locator('summary') .getByRole('button', { name: '日本語の操作' })
.click()
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '編集', exact: true })
.click() .click()
await page.getByRole('menuitem', { name: '編集', exact: true }).click()
const replaced = await executeTool(page, 'set_deck', { const replaced = await executeTool(page, 'set_deck', {
deckId: deck.id, deckId: deck.id,
title: '更新した調査', title: '更新した調査',
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from '../fixtures'
import { closeMobileSidebar, showResearch } from '../workspace'
test('keeps the sidebar, composer and deck reachable at compact widths', async ({
page,
a11y,
}, testInfo) => {
await page.goto('/')
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 })
await showResearch(page)
const chat = page.getByRole('complementary', { name: '調査チャット' })
await expect(chat).toBeVisible()
const composer = chat.getByRole('textbox', {
name: 'メッセージ',
exact: true,
})
await expect(composer).toBeVisible()
const bounds = await composer.boundingBox()
if (!bounds) throw new Error('Composer must have a visible bounding box')
expect(bounds.x).toBeGreaterThanOrEqual(0)
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width)
expect(bounds.y + bounds.height).toBeLessThanOrEqual(850)
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({
path: testInfo.outputPath(`workspace-${width}.png`),
})
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
await closeMobileSidebar(page)
}
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
await expect(
page.getByRole('complementary', { name: '調査チャット' }),
).toBeHidden()
await page.getByRole('button', { name: '調査チャット', exact: true }).click()
await expect(
page.getByRole('complementary', { name: '調査チャット' }),
).toBeVisible()
})
+39
View File
@@ -0,0 +1,39 @@
import { expect, type Page } from '@playwright/test'
export async function showDeckManagement(page: Page) {
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled()
const navigation = page.getByRole('button', {
name: 'デッキ管理',
exact: true,
})
if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
}
await navigation.click()
}
export async function showResearch(page: Page) {
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled()
const navigation = page.getByRole('button', {
name: '調査チャット',
exact: true,
})
if (!(await navigation.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
}
await navigation.click()
}
export async function closeMobileSidebar(page: Page) {
const sheet = page.locator('[data-mobile="true"][data-sidebar="sidebar"]')
if (await sheet.isVisible()) {
await page
.getByRole('button', { name: 'サイドバーを閉じる', exact: true })
.click()
await expect(sheet).toBeHidden()
}
}
+1 -1
View File
@@ -22,7 +22,7 @@
inherit (finalAttrs) pname version src; inherit (finalAttrs) pname version src;
pnpm = pkgs.pnpm_11; pnpm = pkgs.pnpm_11;
fetcherVersion = 4; fetcherVersion = 4;
hash = "sha256-dV9yHImzYzkwE5ZK75zWLkpJ5+V0Rvqg8/zC0zRw76I="; hash = "sha256-+DqNt+58TrI0W8SU//JNdYYJtCdKNIG7gfWRxno36QQ=";
}; };
nativeBuildInputs = with pkgs; [ nativeBuildInputs = with pkgs; [
+19 -2
View File
@@ -12,7 +12,12 @@
], ],
"ignore": [ "ignore": [
"e2e/generated/**" "e2e/generated/**"
],
"ignoreIssues": {
"src/components/ui/**": [
"exports"
] ]
}
}, },
"imports": { "imports": {
"#/*": "./src/*" "#/*": "./src/*"
@@ -29,28 +34,36 @@
"build": "vite build && node scripts/build-tools.mjs", "build": "vite build && node scripts/build-tools.mjs",
"start": "HOST=127.0.0.1 node .output/server/index.mjs", "start": "HOST=127.0.0.1 node .output/server/index.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome check .", "lint": "biome check . && pnpm lint:ui",
"knip": "knip", "knip": "knip",
"format": "biome check --write .", "format": "biome check --write .",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts" "test:live": "TWITTER_LITE_LIVE=1 vitest run tests/live/relay.test.ts",
"lint:ui": "oxlint src"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "1.8.0",
"@shadcn/react": "^0.3.1",
"@tanstack/react-query": "5.101.2", "@tanstack/react-query": "5.101.2",
"@tanstack/react-router": "1.170.17", "@tanstack/react-router": "1.170.17",
"@tanstack/react-router-ssr-query": "1.167.1", "@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.27", "@tanstack/react-start": "1.168.27",
"@yuta/bird": "0.10.1", "@yuta/bird": "0.10.1",
"better-sqlite3": "13.0.3", "better-sqlite3": "13.0.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
"drizzle-orm": "0.45.3", "drizzle-orm": "0.45.3",
"lucide-react": "1.48.0",
"nitro": "3.0.260610-beta", "nitro": "3.0.260610-beta",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"remark-gfm": "4.0.1", "remark-gfm": "4.0.1",
"sanitize-html": "2.17.7", "sanitize-html": "2.17.7",
"shadcn": "4.21.0",
"tw-animate-css": "1.4.0",
"usewebmcp": "5.1.0", "usewebmcp": "5.1.0",
"zod": "4.4.3" "zod": "4.4.3"
}, },
@@ -58,6 +71,8 @@
"@axe-core/playwright": "4.11.1", "@axe-core/playwright": "4.11.1",
"@biomejs/biome": "2.5.3", "@biomejs/biome": "2.5.3",
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "10.4.1", "@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2", "@testing-library/react": "16.3.2",
@@ -71,6 +86,8 @@
"jsdom": "29.1.1", "jsdom": "29.1.1",
"knip": "6.27.0", "knip": "6.27.0",
"orval": "8.24.0", "orval": "8.24.0",
"oxlint": "^1.85.0",
"tailwindcss": "^4.3.3",
"tsx": "4.23.12", "tsx": "4.23.12",
"typescript": "7.0.2", "typescript": "7.0.2",
"vite": "8.1.4", "vite": "8.1.4",
+2655 -4
View File
File diff suppressed because it is too large Load Diff
+143 -15
View File
@@ -1,21 +1,149 @@
export function AppShell({ import { Columns3, MessageSquare, PanelLeftClose, Search } from 'lucide-react'
sidebar, import { type CSSProperties, type ReactNode, useState } from 'react'
researchChat, import {
children, Sidebar,
}: { SidebarContent,
sidebar: React.ReactNode SidebarFooter,
researchChat: React.ReactNode SidebarGroup,
children: React.ReactNode SidebarGroupContent,
}) { SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
type ShellProps = {
sidebar: ReactNode
researchChat: (closeMobileSidebar: () => void) => ReactNode
children: ReactNode
}
export function AppShell(props: ShellProps) {
return ( return (
<main className="app app-deck"> <TooltipProvider delay={800}>
{researchChat} <SidebarProvider
<section className="reader"> className="workspace h-dvh min-h-0 overflow-hidden"
<section className="deck-toolbar" aria-label="デッキ管理"> style={{ '--sidebar-width': '25rem' } as CSSProperties}
{sidebar} >
<Workspace {...props} />
</SidebarProvider>
</TooltipProvider>
)
}
function Workspace({ sidebar, researchChat, children }: ShellProps) {
const [panel, setPanel] = useState<'chat' | 'decks'>('chat')
const { setOpen, setOpenMobile, isMobile } = useSidebar()
const navigation = [
{ id: 'chat', title: '調査チャット', icon: MessageSquare },
{ id: 'decks', title: 'デッキ管理', icon: Columns3 },
] as const
return (
<>
<Sidebar
collapsible="icon"
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
>
<Sidebar
collapsible="none"
className="w-[calc(var(--sidebar-width-icon)+1px)]! shrink-0 border-r"
>
<SidebarHeader>
<div
className="flex size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
role="img"
aria-label="Twitter Lite"
>
<Search className="size-4" aria-hidden="true" />
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton
aria-label={item.title}
tooltip={{ children: item.title, hidden: false }}
isActive={panel === item.id}
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
collapsible="none"
className="min-w-0 flex-1 group-data-[collapsible=icon]:hidden"
>
<div
className={
panel === 'chat' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'
}
>
{researchChat(() => setOpenMobile(false))}
</div>
<section
aria-label="デッキ管理"
className={
panel === 'decks'
? 'flex min-h-0 flex-1 flex-col overflow-y-auto p-4'
: 'hidden'
}
>
<h2 className="mb-4 text-base font-semibold">デッキ</h2>
<div className="flex flex-col gap-4">{sidebar}</div>
</section> </section>
</Sidebar>
</Sidebar>
<SidebarInset className="min-w-0 overflow-hidden">
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<SidebarTrigger aria-label="サイドバーを切り替え" />
<span className="text-sm font-medium">Twitter Lite</span>
<span className="ml-auto text-xs text-muted-foreground">
調査ワークスペース
</span>
</header>
<section
className="reader flex min-h-0 min-w-0 flex-1 flex-col"
aria-label="デッキ"
>
{children} {children}
</section> </section>
</main> </SidebarInset>
</>
) )
} }
+31 -31
View File
@@ -1,5 +1,12 @@
import { useEffect, useId, useRef } from 'react'
import { Icon } from './icon' import { Icon } from './icon'
import { Button } from './ui/button'
import {
DialogClose,
DialogContent,
DialogHeader,
Dialog as DialogRoot,
DialogTitle,
} from './ui/dialog'
export function Dialog({ export function Dialog({
title, title,
@@ -10,42 +17,35 @@ export function Dialog({
onClose: () => void onClose: () => void
children: React.ReactNode children: React.ReactNode
}) { }) {
const dialog = useRef<HTMLDialogElement>(null)
const headingId = useId()
useEffect(() => {
const element = dialog.current
const opener =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
element?.showModal()
return () => {
element?.close()
if (opener?.isConnected) opener.focus({ preventScroll: true })
}
}, [])
return ( return (
<dialog <DialogRoot
ref={dialog} open
className="deck-dialog" onOpenChange={(open) => {
aria-labelledby={headingId} if (!open) onClose()
onCancel={(event) => {
event.preventDefault()
onClose()
}} }}
> >
<div className="dialog-heading"> <DialogContent
<h2 id={headingId}>{title}</h2> showCloseButton={false}
<button className="max-h-[calc(100dvh-2rem)] overflow-y-auto"
type="button" aria-describedby={undefined}
className="icon-button" >
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DialogClose
render={
<Button
variant="ghost"
size="icon-sm"
className="absolute right-2 top-2"
aria-label="閉じる" aria-label="閉じる"
onClick={onClose} />
}
> >
<Icon name="close" /> <Icon name="close" />
</button> </DialogClose>
</div>
{children} {children}
</dialog> </DialogContent>
</DialogRoot>
) )
} }
+106
View File
@@ -0,0 +1,106 @@
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'
import { cn } from 'cn'
import type * as React from 'react'
function Avatar({
className,
size = 'default',
...props
}: AvatarPrimitive.Root.Props & {
size?: 'default' | 'sm' | 'lg'
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
'group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
className,
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
'aspect-square size-full rounded-full object-cover',
className,
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs',
className,
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="avatar-badge"
className={cn(
'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none',
'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
className,
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group"
className={cn(
'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background',
className,
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="avatar-group-count"
className={cn(
'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
className,
)}
{...props}
/>
)
}
export {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarImage,
}
+51
View File
@@ -0,0 +1,51 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
const badgeVariants = cva(
'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
secondary:
'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
destructive:
'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
outline:
'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
ghost:
'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
link: 'text-primary underline-offset-4 hover:underline',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant = 'default',
render,
...props
}: useRender.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: 'span',
props: mergeProps<'span'>(
{
className: cn(badgeVariants({ variant }), className),
},
props,
),
render,
state: {
slot: 'badge',
variant,
},
})
}
export { Badge, badgeVariants }
+127
View File
@@ -0,0 +1,127 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="bubble-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
{...props}
/>
)
}
const bubbleVariants = cva(
'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full',
{
variants: {
variant: {
default:
'*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80',
secondary:
'*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]',
muted:
'*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]',
tinted:
'*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]',
outline:
'*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30',
ghost:
'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50',
destructive:
'*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Bubble({
variant = 'default',
align = 'start',
className,
...props
}: React.ComponentProps<'div'> &
VariantProps<typeof bubbleVariants> & {
align?: 'start' | 'end'
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
)
}
function BubbleContent({
className,
render,
...props
}: useRender.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
'w-fit max-w-full min-w-0 overflow-hidden rounded-xl border border-transparent px-3 py-2 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/50',
className,
),
},
props,
),
render,
state: {
slot: 'bubble-content',
},
})
}
const bubbleReactionsVariants = cva(
'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0',
{
variants: {
side: {
top: 'top-0 -translate-y-3/4',
bottom: 'bottom-0 translate-y-3/4',
},
align: {
start: 'left-3',
end: 'right-3',
},
},
defaultVariants: {
side: 'bottom',
align: 'end',
},
},
)
function BubbleReactions({
side = 'bottom',
align = 'end',
className,
...props
}: React.ComponentProps<'div'> & {
align?: 'start' | 'end'
side?: 'top' | 'bottom'
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
)
}
export { Bubble, BubbleContent, BubbleGroup, BubbleReactions }
+57
View File
@@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from '@base-ui/react/button'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
outline:
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost:
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive:
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default:
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
icon: 'size-8',
'icon-xs':
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm':
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant = 'default',
size = 'default',
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+25
View File
@@ -0,0 +1,25 @@
import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
import { cn } from 'cn'
import { CheckIcon } from 'lucide-react'
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+19
View File
@@ -0,0 +1,19 @@
import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleContent, CollapsibleTrigger }
+153
View File
@@ -0,0 +1,153 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import { cn } from 'cn'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 isolate z-50 bg-overlay duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0',
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
'fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<'div'> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
'-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-base leading-none font-medium', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
'text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+271
View File
@@ -0,0 +1,271 @@
'use client'
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { cn } from 'cn'
import { CheckIcon, ChevronRightIcon } from 'lucide-react'
import type * as React from 'react'
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = 'start',
alignOffset = 0,
side = 'bottom',
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
'z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
'px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7',
className,
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = 'start',
alignOffset = -3,
side = 'right',
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
'w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground',
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
}
+100
View File
@@ -0,0 +1,100 @@
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex w-full min-w-0 flex-1 flex-col items-center justify-center gap-4 rounded-xl border-dashed p-6 text-center text-balance',
className,
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn('flex max-w-sm flex-col items-center gap-2', className)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: 'default',
},
},
)
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-sm font-medium tracking-tight', className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-sm/relaxed text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
className,
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full max-w-sm min-w-0 flex-col items-center gap-2.5 text-sm text-balance',
className,
)}
{...props}
/>
)
}
export {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
}
+158
View File
@@ -0,0 +1,158 @@
'use client'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Input } from '#/components/ui/input.tsx'
import { Textarea } from '#/components/ui/textarea.tsx'
function InputGroup({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="input-group"
className={cn(
'group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5',
className,
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
'inline-start':
'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]',
'inline-end':
'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]',
'block-start':
'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2',
'block-end':
'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2',
},
},
defaultVariants: {
align: 'inline-start',
},
},
)
function InputGroupAddon({
className,
align = 'inline-start',
...props
}: React.ComponentProps<'fieldset'> &
VariantProps<typeof inputGroupAddonVariants>) {
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: Pointer convenience only; the text control remains independently keyboard-focusable.
<fieldset
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest('button')) {
return
}
e.currentTarget.parentElement?.querySelector('input')?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
'flex items-center gap-2 text-sm shadow-none',
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: '',
'icon-xs':
'size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0',
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
},
},
defaultVariants: {
size: 'xs',
},
},
)
function InputGroupButton({
className,
type = 'button',
variant = 'ghost',
size = 'xs',
...props
}: Omit<React.ComponentProps<typeof Button>, 'size' | 'type'> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: 'button' | 'submit' | 'reset'
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<'input'>) {
return (
<Input
data-slot="input-group-control"
className={cn(
'flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className,
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<'textarea'>) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
'flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent',
className,
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
}
+19
View File
@@ -0,0 +1,19 @@
import { Input as InputPrimitive } from '@base-ui/react/input'
import { cn } from 'cn'
import type * as React from 'react'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
'h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { cn } from 'cn'
import type * as React from 'react'
function Label({ className, ...props }: React.ComponentProps<'label'>) {
return (
// biome-ignore lint/a11y/noLabelWithoutControl: Association and content are supplied by the caller through props.
<label
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }
+70
View File
@@ -0,0 +1,70 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import type * as React from 'react'
const markerVariants = cva(
"group/marker relative flex min-h-4 w-full items-center gap-2 text-left text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [a]:underline [a]:underline-offset-3 [a]:hover:text-foreground",
{
variants: {
variant: {
default: '',
separator:
'before:mr-1 before:h-px before:min-w-0 before:flex-1 before:bg-border after:ml-1 after:h-px after:min-w-0 after:flex-1 after:bg-border',
border: 'border-b border-border pb-2',
},
},
},
)
function Marker({
className,
variant = 'default',
render,
...props
}: useRender.ComponentProps<'div'> & VariantProps<typeof markerVariants>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(markerVariants({ variant, className })),
},
props,
),
render,
state: {
slot: 'marker',
variant,
},
})
}
function MarkerIcon({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="marker-icon"
aria-hidden="true"
className={cn(
"size-4 shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function MarkerContent({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="marker-content"
className={cn(
'min-w-0 wrap-break-word group-data-[variant=separator]/marker:flex-none group-data-[variant=separator]/marker:text-center *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground',
className,
)}
{...props}
/>
)
}
export { Marker, MarkerContent, MarkerIcon, markerVariants }
+129
View File
@@ -0,0 +1,129 @@
'use client'
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from '@shadcn/react/message-scroller'
import { cn } from 'cn'
import { ArrowDownIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
className,
)}
{...props}
/>
)
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-autoscrolling:scrollbar-thumb-transparent data-autoscrolling:scrollbar-track-transparent data-pending-scroll:invisible',
className,
)}
{...props}
/>
)
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn('flex h-max min-h-full flex-col gap-6', className)}
{...props}
/>
)
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]',
className,
)}
{...props}
/>
)
}
function MessageScrollerButton({
direction = 'end',
className,
children,
render,
variant = 'secondary',
size = 'icon-sm',
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, 'variant' | 'size'>) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180',
className,
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon />
<span className="sr-only">
{direction === 'end' ? 'Scroll to end' : 'Scroll to start'}
</span>
</>
)}
</MessageScrollerPrimitive.Button>
)
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
}
+91
View File
@@ -0,0 +1,91 @@
import { cn } from 'cn'
import type * as React from 'react'
function MessageGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-group"
className={cn('flex min-w-0 flex-col gap-2', className)}
{...props}
/>
)
}
function Message({
className,
align = 'start',
...props
}: React.ComponentProps<'div'> & { align?: 'start' | 'end' }) {
return (
<div
data-slot="message"
data-align={align}
className={cn(
'group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse',
className,
)}
{...props}
/>
)
}
function MessageAvatar({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-avatar"
className={cn(
'flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8',
className,
)}
{...props}
/>
)
}
function MessageContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-content"
className={cn(
'flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:*:data-slot:self-end',
className,
)}
{...props}
/>
)
}
function MessageHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-header"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0',
className,
)}
{...props}
/>
)
}
function MessageFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="message-footer"
className={cn(
'flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0 group-data-[align=end]/message:justify-end',
className,
)}
{...props}
/>
)
}
export {
Message,
MessageAvatar,
MessageContent,
MessageFooter,
MessageGroup,
MessageHeader,
}
+64
View File
@@ -0,0 +1,64 @@
import { cn } from 'cn'
import { ChevronDownIcon } from 'lucide-react'
import type * as React from 'react'
type NativeSelectProps = Omit<React.ComponentProps<'select'>, 'size'> & {
size?: 'sm' | 'default'
}
function NativeSelect({
className,
size = 'default',
...props
}: NativeSelectProps) {
return (
<div
className={cn(
'group/native-select relative w-fit has-[select:disabled]:opacity-50',
className,
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon
className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
aria-hidden="true"
data-slot="native-select-icon"
/>
</div>
)
}
function NativeSelectOption({
className,
...props
}: React.ComponentProps<'option'>) {
return (
<option
data-slot="native-select-option"
className={cn('bg-[Canvas] text-[CanvasText]', className)}
{...props}
/>
)
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<'optgroup'>) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn('bg-[Canvas] text-[CanvasText]', className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
+22
View File
@@ -0,0 +1,22 @@
import { Separator as SeparatorPrimitive } from '@base-ui/react/separator'
import { cn } from 'cn'
function Separator({
className,
orientation = 'horizontal',
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
'shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch',
className,
)}
{...props}
/>
)
}
export { Separator }
+133
View File
@@ -0,0 +1,133 @@
import { Dialog as SheetPrimitive } from '@base-ui/react/dialog'
import { cn } from 'cn'
import { XIcon } from 'lucide-react'
import type * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
'fixed inset-0 z-50 bg-overlay transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs',
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = 'right',
showCloseButton = true,
keepMounted = false,
...props
}: SheetPrimitive.Popup.Props & {
side?: 'top' | 'right' | 'bottom' | 'left'
showCloseButton?: boolean
keepMounted?: boolean
}) {
return (
<SheetPortal keepMounted={keepMounted}>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-header"
className={cn('flex flex-col gap-0.5 p-4', className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sheet-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn('text-base font-medium text-foreground', className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
}
+716
View File
@@ -0,0 +1,716 @@
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from 'cn'
import { PanelLeftIcon } from 'lucide-react'
import * as React from 'react'
import { Button } from '#/components/ui/button.tsx'
import { Input } from '#/components/ui/input.tsx'
import { Separator } from '#/components/ui/separator.tsx'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '#/components/ui/sheet.tsx'
import { Skeleton } from '#/components/ui/skeleton.tsx'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '#/components/ui/tooltip.tsx'
import { useIsMobile } from '#/hooks/use-mobile.ts'
const SIDEBAR_WIDTH = '16rem'
const SIDEBAR_WIDTH_MOBILE = 'min(25rem, 100vw)'
const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
type SidebarContextProps = {
state: 'expanded' | 'collapsed'
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider.')
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<'div'> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === 'function' ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? 'expanded' : 'collapsed'
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
'--sidebar-width': SIDEBAR_WIDTH,
'--sidebar-width-icon': SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className,
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
className,
children,
dir,
...props
}: React.ComponentProps<'div'> & {
side?: 'left' | 'right'
variant?: 'sidebar' | 'floating' | 'inset'
collapsible?: 'offcanvas' | 'icon' | 'none'
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === 'none') {
return (
<div
data-slot="sidebar"
className={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
className,
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
keepMounted
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width)! max-w-none! bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
'--sidebar-width': SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>ワークスペース</SheetTitle>
<SheetDescription>調査チャットとデッキ管理</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-row">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)',
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
'fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<'button'>) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
'absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<'main'>) {
return (
<main
data-slot="sidebar-inset"
className={cn(
'relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2',
className,
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn('h-8 w-full bg-background shadow-none', className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn('flex flex-col gap-2 p-2', className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn('mx-2 w-auto bg-sidebar-border', className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
'no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden',
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn('relative flex w-full min-w-0 flex-col p-2', className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<'div'> & React.ComponentProps<'div'>) {
return useRender({
defaultTagName: 'div',
props: mergeProps<'div'>(
{
className: cn(
'flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-group-label',
sidebar: 'group-label',
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<'button'> & React.ComponentProps<'button'>) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: cn(
'absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-group-action',
sidebar: 'group-action',
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn('w-full text-sm', className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn('flex w-full min-w-0 flex-col gap-0', className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn('group/menu-item relative', className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
'peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate',
{
variants: {
variant: {
default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
outline:
'bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]',
},
size: {
default: 'h-8 text-sm',
sm: 'h-7 text-xs',
lg: 'h-12 text-sm group-data-[collapsible=icon]:p-0!',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function SidebarMenuButton({
render,
isActive = false,
variant = 'default',
size = 'default',
tooltip,
className,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props,
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: 'sidebar-menu-button',
sidebar: 'menu-button',
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === 'string') {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== 'collapsed' || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<'button'> &
React.ComponentProps<'button'> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: 'button',
props: mergeProps<'button'>(
{
className: cn(
'absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0',
showOnHover &&
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-menu-action',
sidebar: 'menu-action',
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
'pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground',
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<'div'> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn('flex h-8 items-center gap-2 rounded-md px-2', className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
'--skeleton-width': width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<'ul'>) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden',
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn('group/menu-sub-item relative', className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = 'md',
isActive = false,
className,
...props
}: useRender.ComponentProps<'a'> &
React.ComponentProps<'a'> & {
size?: 'sm' | 'md'
isActive?: boolean
}) {
return useRender({
defaultTagName: 'a',
props: mergeProps<'a'>(
{
className: cn(
'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground',
className,
),
},
props,
),
render,
state: {
slot: 'sidebar-menu-sub-button',
sidebar: 'menu-sub-button',
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from 'cn'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
)
}
export { Skeleton }
+17
View File
@@ -0,0 +1,17 @@
import { cn } from 'cn'
import type * as React from 'react'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
className,
)}
{...props}
/>
)
}
export { Textarea }
+65
View File
@@ -0,0 +1,65 @@
'use client'
import { Tooltip as TooltipPrimitive } from '@base-ui/react/tooltip'
import { cn } from 'cn'
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = 'top',
sideOffset = 4,
align = 'center',
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
'align' | 'alignOffset' | 'side' | 'sideOffset'
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
'z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
@@ -5,7 +5,7 @@
.connection-manager-description, .connection-manager-description,
.connection-manager-note { .connection-manager-note {
margin: 0; margin: 0;
color: #aab8c2; color: var(--muted-foreground);
font-size: 0.875rem; font-size: 0.875rem;
} }
.connection-manager-list { .connection-manager-list {
@@ -21,7 +21,7 @@
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.75rem;
padding-block: 0.75rem; padding-block: 0.75rem;
border-bottom: 1px solid #38444d; border-bottom: 1px solid var(--border);
} }
.connection-manager-account { .connection-manager-account {
display: grid; display: grid;
@@ -29,15 +29,9 @@
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.connection-manager-account > span { .connection-manager-account > span:not([data-slot="badge"]) {
font-size: 0.8rem; font-size: 0.8rem;
color: #aab8c2; color: var(--muted-foreground);
}
.connection-manager-account > .connection-manager-status-connected {
color: #71d6ad;
}
.connection-manager-account > .connection-manager-status-expired {
color: #f2c66d;
} }
.connection-manager-actions { .connection-manager-actions {
display: flex; display: flex;
@@ -53,18 +47,6 @@
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
} }
.connection-manager select {
width: 100%;
min-width: 0;
background: #15202b;
color: #e7e9ea;
border: 1px solid #536471;
border-radius: 0.25rem;
padding: 0.6rem;
}
.connection-manager button {
min-height: 2.5rem;
}
@media (max-width: 480px) { @media (max-width: 480px) {
.connection-manager-list > li { .connection-manager-list > li {
align-items: flex-start; align-items: flex-start;
+29 -16
View File
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from '@tanstack/react-start'
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { import {
disconnectMastodonAccount, disconnectMastodonAccount,
loadMastodonInstances, loadMastodonInstances,
@@ -92,15 +96,18 @@ export function ConnectionManager({
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '} {connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{new URL(connection.origin).hostname} {new URL(connection.origin).hostname}
</span> </span>
<span <Badge
className={`connection-manager-status connection-manager-status-${connection.status}`} variant={
connection.status === 'connected' ? 'secondary' : 'outline'
}
> >
{statusNames[connection.status]} {statusNames[connection.status]}
</span> </Badge>
</div> </div>
{connection.platform === 'mastodon' && ( {connection.platform === 'mastodon' && (
<div className="connection-manager-actions"> <div className="connection-manager-actions">
<button <Button
variant="outline"
type="button" type="button"
disabled={busy} disabled={busy}
aria-label={`${connection.displayName}を再接続`} aria-label={`${connection.displayName}を再接続`}
@@ -109,16 +116,17 @@ export function ConnectionManager({
} }
> >
再接続 再接続
</button> </Button>
{connection.status !== 'disconnected' && ( {connection.status !== 'disconnected' && (
<button <Button
variant="destructive"
type="button" type="button"
disabled={busy} disabled={busy}
aria-label={`${connection.displayName}の接続を解除`} aria-label={`${connection.displayName}の接続を解除`}
onClick={() => void remove(connection.id)} onClick={() => void remove(connection.id)}
> >
接続解除 接続解除
</button> </Button>
)} )}
</div> </div>
)} )}
@@ -139,30 +147,35 @@ export function ConnectionManager({
) : instances.isError ? ( ) : instances.isError ? (
<p role="alert"> <p role="alert">
接続先を取得できませんでした。 接続先を取得できませんでした。
<button type="button" onClick={() => void instances.refetch()}> <Button
variant="outline"
type="button"
onClick={() => void instances.refetch()}
>
再試行 再試行
</button> </Button>
</p> </p>
) : instances.data.origins.length === 0 ? ( ) : instances.data.origins.length === 0 ? (
<p>利用するMastodonサーバーがまだ設定されていません。</p> <p>利用するMastodonサーバーがまだ設定されていません。</p>
) : ( ) : (
<> <>
<label htmlFor={selectId}>Mastodonサーバー</label> <Label htmlFor={selectId}>Mastodonサーバー</Label>
<select <NativeSelect
className="w-full"
id={selectId} id={selectId}
value={origin} value={origin}
onChange={(event) => setSelected(event.target.value)} onChange={(event) => setSelected(event.target.value)}
disabled={busy} disabled={busy}
> >
{instances.data.origins.map((value) => ( {instances.data.origins.map((value) => (
<option key={value} value={value}> <NativeSelectOption key={value} value={value}>
{new URL(value).hostname} {new URL(value).hostname}
</option> </NativeSelectOption>
))} ))}
</select> </NativeSelect>
<button type="submit" disabled={busy || !origin}> <Button type="submit" disabled={busy || !origin}>
Mastodonで認可する Mastodonで認可する
</button> </Button>
</> </>
)} )}
{hasTemporaryDecks && ( {hasTemporaryDecks && (
+2 -2
View File
@@ -162,7 +162,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
fireEvent.change(screen.getByLabelText('表示順'), { fireEvent.change(screen.getByLabelText('表示順'), {
target: { value: 'Top' }, target: { value: 'Top' },
}) })
fireEvent.click(screen.getByLabelText('フォロー中のみ')) fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce()) await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査') expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
@@ -170,7 +170,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
'from:owner WebMCP', 'from:owner WebMCP',
) )
expect(screen.getByLabelText('表示順')).toHaveValue('Top') expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked() expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' })) fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2)) await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source) expect(save.mock.calls[1]?.[0].source).toEqual(save.mock.calls[0]?.[0].source)
+22 -21
View File
@@ -1,4 +1,8 @@
import { useId, useState } from 'react' import { useId, useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { Connection } from '#/features/connections/model' import type { Connection } from '#/features/connections/model'
import { import {
ColumnSourceEditor, ColumnSourceEditor,
@@ -60,9 +64,9 @@ export function ColumnEditor({
}} }}
> >
<div className="deck-editor-fields"> <div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}> <Label htmlFor={`${formId}-title`}>
カラム名 カラム名
<input <Input
id={`${formId}-title`} id={`${formId}-title`}
name="title" name="title"
defaultValue={column?.title ?? ''} defaultValue={column?.title ?? ''}
@@ -70,10 +74,11 @@ export function ColumnEditor({
placeholder="例:発表元の投稿" placeholder="例:発表元の投稿"
required required
/> />
</label> </Label>
<label htmlFor={`${formId}-profile`}> <Label htmlFor={`${formId}-profile`}>
接続プロファイル 接続プロファイル
<select <NativeSelect
className="w-full"
id={`${formId}-profile`} id={`${formId}-profile`}
value={connectionId} value={connectionId}
required required
@@ -86,22 +91,22 @@ export function ColumnEditor({
setSource(rebindColumnSource(source, connection.platform)) setSource(rebindColumnSource(source, connection.platform))
}} }}
> >
<option value="" disabled> <NativeSelectOption value="" disabled>
選択してください 選択してください
</option> </NativeSelectOption>
{connectionId && !validConnection && ( {connectionId && !validConnection && (
<option value={connectionId} disabled> <NativeSelectOption value={connectionId} disabled>
{connectionId}(利用不可) {connectionId}(利用不可)
</option> </NativeSelectOption>
)} )}
{available.map((connection) => ( {available.map((connection) => (
<option key={connection.id} value={connection.id}> <NativeSelectOption key={connection.id} value={connection.id}>
{connection.displayName} {connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''} {connection.platform === 'mastodon' ? ' · Mastodon' : ''}
</option> </NativeSelectOption>
))} ))}
</select> </NativeSelect>
</label> </Label>
</div> </div>
<ColumnSourceEditor <ColumnSourceEditor
source={source} source={source}
@@ -117,16 +122,12 @@ export function ColumnEditor({
</p> </p>
)} )}
<div className="deck-actions"> <div className="deck-actions">
<button <Button type="submit" disabled={!validConnection}>
type="submit"
className="deck-primary"
disabled={!validConnection}
>
{column ? '変更を保存' : '追加して検索'} {column ? '変更を保存' : '追加して検索'}
</button> </Button>
<button type="button" onClick={onCancel}> <Button variant="outline" type="button" onClick={onCancel}>
キャンセル キャンセル
</button> </Button>
</div> </div>
</form> </form>
) )
+44 -41
View File
@@ -1,4 +1,13 @@
import { Icon } from '#/components/icon' import { Icon } from '#/components/icon'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '#/components/ui/dropdown-menu'
import { import {
flattenResearchPages, flattenResearchPages,
useResearchFeed, useResearchFeed,
@@ -53,8 +62,9 @@ export function ResearchColumn({
<div className="deck-column-title"> <div className="deck-column-title">
<PlatformIcon platform={column.source.platform} /> <PlatformIcon platform={column.source.platform} />
<h2 id={headingId}>{column.title}</h2> <h2 id={headingId}>{column.title}</h2>
<button <Button
className="icon-button" variant="ghost"
size="icon-sm"
aria-label="更新" aria-label="更新"
title="更新" title="更新"
type="button" type="button"
@@ -62,55 +72,46 @@ export function ResearchColumn({
disabled={query.isFetching} disabled={query.isFetching}
> >
<Icon name="refresh" /> <Icon name="refresh" />
</button> </Button>
<details className="column-menu"> <DropdownMenu>
<summary <DropdownMenuTrigger
className="icon-button" render={
<Button
variant="ghost"
size="icon-sm"
aria-label={`${column.title}の操作`} aria-label={`${column.title}の操作`}
title="カラムの操作" title="カラムの操作"
/>
}
> >
<Icon name="more" /> <Icon name="more" />
</summary> </DropdownMenuTrigger>
<div className="column-menu-panel"> <DropdownMenuContent align="end">
<button type="button" onClick={onEdit}> <DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
編集 <DropdownMenuItem
</button> onClick={() => onMove(-1)}
<button
type="button"
onClick={(event) => {
onMove(-1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={first} disabled={first}
aria-label={`${column.title}を左へ`} aria-label={`${column.title}を左へ`}
> >
← 左へ移動 ← 左へ移動
</button> </DropdownMenuItem>
<button <DropdownMenuItem
type="button" onClick={() => onMove(1)}
onClick={(event) => {
onMove(1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
disabled={last} disabled={last}
aria-label={`${column.title}を右へ`} aria-label={`${column.title}を右へ`}
> >
右へ移動 → 右へ移動 →
</button> </DropdownMenuItem>
<button <DropdownMenuSeparator />
type="button" <DropdownMenuItem
className="danger" variant="destructive"
onClick={onRemove} onClick={onRemove}
aria-label={`${column.title}を削除`} aria-label={`${column.title}を削除`}
> >
カラムを削除 カラムを削除
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
</details> </DropdownMenu>
</div> </div>
<div className="deck-column-label"> <div className="deck-column-label">
<span <span
@@ -118,10 +119,10 @@ export function ResearchColumn({
> >
{connectionLabel ?? '接続が見つかりません'} {connectionLabel ?? '接続が見つかりません'}
</span> </span>
<span className="deck-source-label"> <Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} /> <Icon name={column.source.kind} />
{sourceDescription.label} {sourceDescription.label}
</span> </Badge>
</div> </div>
<p className="deck-query">{sourceDescription.query}</p> <p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status"> <p className="deck-column-status" role="status">
@@ -152,7 +153,8 @@ export function ResearchColumn({
<div className="deck-message" role="alert"> <div className="deck-message" role="alert">
<p>{error.message}</p> <p>{error.message}</p>
{error.retryable ? ( {error.retryable ? (
<button <Button
variant="outline"
type="button" type="button"
disabled={query.isFetching} disabled={query.isFetching}
onClick={() => onClick={() =>
@@ -162,7 +164,7 @@ export function ResearchColumn({
} }
> >
再試行 再試行
</button> </Button>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -171,13 +173,14 @@ export function ResearchColumn({
) : null} ) : null}
{query.hasNextPage && !query.isFetchNextPageError ? ( {query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message"> <div className="deck-message">
<button <Button
variant="outline"
type="button" type="button"
disabled={query.isFetching} disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })} onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
> >
続きを読み込む 続きを読み込む
</button> </Button>
</div> </div>
) : null} ) : null}
{!query.isPending && {!query.isPending &&
+114 -55
View File
@@ -5,6 +5,16 @@ import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell' import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog' import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon' import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '#/components/ui/empty'
import { Input } from '#/components/ui/input'
import { ConnectionManager } from '#/features/connections/connection-manager' 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'
@@ -32,7 +42,9 @@ export function DeckPage() {
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 [managingConnections, setManagingConnections] = useState(false)
const [switcherEditing, setSwitcherEditing] = useState(false) const [deckDialog, setDeckDialog] = useState<'create' | 'delete' | null>(null)
const [newDeckTitle, setNewDeckTitle] = useState('')
const switcherEditing = deckDialog !== null
const { const {
deck, deck,
workspace, workspace,
@@ -83,6 +95,8 @@ export function DeckPage() {
}) })
function clearEditors() { function clearEditors() {
setDeckDialog(null)
setNewDeckTitle('')
setEditing(null) setEditing(null)
setRenaming(false) setRenaming(false)
setRemoved(undefined) setRemoved(undefined)
@@ -102,9 +116,24 @@ export function DeckPage() {
return saved return saved
} }
const storageErrorNotice = storageError ? (
<p className="deck-error" role="alert">
{storageError}
<Button
variant="outline"
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</Button>
</p>
) : null
return ( return (
<AppShell
researchChat={
<ResearchPanel <ResearchPanel
onNavigateCitation={citationNavigation.navigate} onNavigateCitation={citationNavigation.navigate}
ready={ready} ready={ready}
@@ -123,16 +152,25 @@ export function DeckPage() {
}) })
if (activate) clearEditors() if (activate) clearEditors()
}} }}
/> >
} {(renderResearchPanel) => (
<AppShell
researchChat={renderResearchPanel}
sidebar={ sidebar={
<> <>
<DeckSwitcher <DeckSwitcher
key={deck.id} key={deck.id}
errorNotice={storageErrorNotice}
decks={workspace.decks} decks={workspace.decks}
activeId={deck.id} activeId={deck.id}
ready={ready && !saving} ready={ready && !saving}
onEditingChange={setSwitcherEditing} dialog={deckDialog}
newTitle={newDeckTitle}
onNewTitleChange={setNewDeckTitle}
onDialogChange={(dialog) => {
setDeckDialog(dialog)
if (dialog !== 'create') setNewDeckTitle('')
}}
onSelect={(id) => { onSelect={(id) => {
select(id) select(id)
clearEditors() clearEditors()
@@ -148,29 +186,32 @@ export function DeckPage() {
return saved return saved
}} }}
/> />
<button <Button
className="deck-primary sidebar-add" className="w-full"
aria-label="カラムを追加" aria-label="カラムを追加"
title="カラムを追加" title="カラムを追加"
type="button" type="button"
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS} disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
}
onClick={() => setEditing('new')} onClick={() => setEditing('new')}
> >
<Icon name="plus" /> <Icon name="plus" />
<span>カラムを追加</span> <span>カラムを追加</span>
</button> </Button>
<p className="sidebar-note"> <p className="sidebar-note">
{deck.persisted {deck.persisted
? 'サーバーに保存・端末間で共有' ? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'} : '一時ビュー · このタブのみ'}
</p> </p>
<button <Button
variant="outline"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => setManagingConnections(true)} onClick={() => setManagingConnections(true)}
> >
接続アカウントを管理 接続アカウントを管理
</button> </Button>
</> </>
} }
> >
@@ -183,7 +224,8 @@ export function DeckPage() {
</div> </div>
<div className="deck-actions"> <div className="deck-actions">
{deck.persisted ? ( {deck.persisted ? (
<button <Button
variant="outline"
type="button" type="button"
disabled={saving} disabled={saving}
onClick={() => { onClick={() => {
@@ -192,17 +234,19 @@ export function DeckPage() {
}} }}
> >
一時コピーを作成 一時コピーを作成
</button> </Button>
) : ( ) : (
<button <Button
variant="outline"
type="button" type="button"
disabled={!ready || saving} disabled={!ready || saving}
onClick={() => void persist(deck.id)} onClick={() => void persist(deck.id)}
> >
デッキとして保存 デッキとして保存
</button> </Button>
)} )}
<button <Button
variant="outline"
className="icon-button" className="icon-button"
aria-label="名前を変更" aria-label="名前を変更"
title="名前を変更" title="名前を変更"
@@ -211,7 +255,7 @@ export function DeckPage() {
onClick={() => setRenaming(!renaming)} onClick={() => setRenaming(!renaming)}
> >
<Icon name="edit" /> <Icon name="edit" />
</button> </Button>
</div> </div>
</div> </div>
{saving ? ( {saving ? (
@@ -222,13 +266,14 @@ export function DeckPage() {
{legacyAvailable ? ( {legacyAvailable ? (
<p className="deck-save-status"> <p className="deck-save-status">
このブラウザに旧デッキがあります。 このブラウザに旧デッキがあります。
<button <Button
variant="outline"
type="button" type="button"
disabled={saving || !profiles.data} disabled={saving || !profiles.data}
onClick={() => void importLegacy()} onClick={() => void importLegacy()}
> >
旧デッキを取り込む 旧デッキを取り込む
</button> </Button>
</p> </p>
) : null} ) : null}
{!ready ? ( {!ready ? (
@@ -239,9 +284,13 @@ export function DeckPage() {
{profiles.isError ? ( {profiles.isError ? (
<p role="alert" className="deck-error"> <p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。 接続プロファイルを取得できませんでした。
<button type="button" onClick={() => void profiles.refetch()}> <Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
再試行 再試行
</button> </Button>
</p> </p>
) : null} ) : null}
{oauthResult === 'failed' ? ( {oauthResult === 'failed' ? (
@@ -267,28 +316,18 @@ export function DeckPage() {
<ConnectionManager <ConnectionManager
connections={profiles.data?.connections ?? []} connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()} onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)} hasTemporaryDecks={workspace.decks.some(
(deck) => !deck.persisted,
)}
/> />
</Dialog> </Dialog>
) : null} ) : null}
{profiles.isPending ? ( {profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p> <p role="status">接続プロファイルを取得しています…</p>
) : null} ) : null}
{storageError ? ( {!renaming && editing === null && !switcherEditing
<p className="deck-error" role="alert"> ? storageErrorNotice
{storageError} : null}
<button
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</button>
</p>
) : null}
{renaming ? ( {renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}> <Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form <form
@@ -304,7 +343,7 @@ export function DeckPage() {
> >
<label htmlFor="deck-title"> <label htmlFor="deck-title">
調査テーマ・デッキ名 調査テーマ・デッキ名
<input <Input
id="deck-title" id="deck-title"
name="title" name="title"
defaultValue={deck.title} defaultValue={deck.title}
@@ -312,8 +351,11 @@ export function DeckPage() {
required required
/> />
</label> </label>
<button type="submit">名前を保存</button> <Button variant="outline" type="submit">
名前を保存
</Button>
</form> </form>
{storageErrorNotice}
</Dialog> </Dialog>
) : null} ) : null}
{editing !== null ? ( {editing !== null ? (
@@ -332,14 +374,18 @@ export function DeckPage() {
onSave={saveColumn} onSave={saveColumn}
onCancel={() => setEditing(null)} onCancel={() => setEditing(null)}
/> />
{storageErrorNotice}
</Dialog> </Dialog>
) : null} ) : null}
{removed ? ( {removed ? (
<div className="deck-undo" role="status"> <div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span> <span>「{removed.column.title}」を削除しました。</span>
<button <Button
variant="outline"
type="button" type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'} disabled={
deck.columns.length >= MAX_COLUMNS || editing === 'new'
}
onClick={async () => { onClick={async () => {
const columns = [...deck.columns] const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column) columns.splice(removed.index, 0, removed.column)
@@ -347,29 +393,37 @@ export function DeckPage() {
}} }}
> >
元に戻す 元に戻す
</button> </Button>
</div> </div>
) : null} ) : null}
{ready && deck.columns.length === 0 && editing === null ? ( {ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty"> <Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon name="columns" /> <Icon name="columns" />
</EmptyMedia>
<EmptyTitle>
<h2>調べたいことから、デッキを作りましょう</h2> <h2>調べたいことから、デッキを作りましょう</h2>
<p> </EmptyTitle>
<EmptyDescription>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。 チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。 検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
</p> </EmptyDescription>
<button </EmptyHeader>
className="deck-primary" <EmptyContent>
type="button" <Button type="button" onClick={() => setEditing('new')}>
onClick={() => setEditing('new')}
>
手動でカラムを追加 手動でカラムを追加
</button> </Button>
</div> </EmptyContent>
</Empty>
) : null} ) : null}
{ready && deck.columns.length > 0 ? ( {ready && deck.columns.length > 0 ? (
<section
className="deck-board"
aria-label="調査カラム"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board. // biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
<section className="deck-board" aria-label="調査カラム" tabIndex={0}> tabIndex={0}
>
{deck.columns.map((column, index) => ( {deck.columns.map((column, index) => (
<ResearchColumn <ResearchColumn
key={`${deck.id}:${column.id}`} key={`${deck.id}:${column.id}`}
@@ -401,7 +455,10 @@ export function DeckPage() {
), ),
}) })
if (saved) setRemoved({ column, index }) if (saved) setRemoved({ column, index })
if (typeof editing === 'object' && editing?.id === column.id) if (
typeof editing === 'object' &&
editing?.id === column.id
)
setEditing(null) setEditing(null)
}} }}
/> />
@@ -409,5 +466,7 @@ export function DeckPage() {
</section> </section>
) : null} ) : null}
</AppShell> </AppShell>
)}
</ResearchPanel>
) )
} }
+60 -43
View File
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react' import type { ReactNode } from 'react'
import { Dialog } from '#/components/dialog' import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon' import { Icon } from '#/components/icon'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { DeckView } from './model' import type { DeckView } from './model'
export function DeckSwitcher({ export function DeckSwitcher({
@@ -10,7 +14,11 @@ export function DeckSwitcher({
onSelect, onSelect,
onCreate, onCreate,
onDelete, onDelete,
onEditingChange, dialog,
onDialogChange,
newTitle,
onNewTitleChange,
errorNotice,
}: { }: {
decks: DeckView[] decks: DeckView[]
activeId: string activeId: string
@@ -18,109 +26,118 @@ export function DeckSwitcher({
onSelect: (id: string) => void onSelect: (id: string) => void
onCreate: (title: string) => Promise<boolean> onCreate: (title: string) => Promise<boolean>
onDelete: () => Promise<boolean> onDelete: () => Promise<boolean>
onEditingChange: (editing: boolean) => void dialog: 'create' | 'delete' | null
onDialogChange: (dialog: 'create' | 'delete' | null) => void
newTitle: string
onNewTitleChange: (title: string) => void
errorNotice?: ReactNode
}) { }) {
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
onEditingChange(creating || deleting)
return () => onEditingChange(false)
}, [creating, deleting, onEditingChange])
return ( return (
<div className="deck-workspaces"> <div className="deck-workspaces">
<div className="deck-actions"> <div className="deck-actions">
<label htmlFor="deck-selection">デッキプロファイル</label> <Label htmlFor="deck-selection">デッキプロファイル</Label>
<select <NativeSelect
className="w-full"
id="deck-selection" id="deck-selection"
value={activeId} value={activeId}
disabled={!ready} disabled={!ready}
onChange={(event) => { onChange={(event) => {
onSelect(event.target.value) onSelect(event.target.value)
setCreating(false) onDialogChange(null)
setDeleting(false)
}} }}
> >
{decks.map((deck) => ( {decks.map((deck) => (
<option key={deck.id} value={deck.id}> <NativeSelectOption key={deck.id} value={deck.id}>
{deck.title} {deck.title}
{deck.persisted ? '' : '(一時)'} {deck.persisted ? '' : '(一時)'}
</option> </NativeSelectOption>
))} ))}
</select> </NativeSelect>
<button <Button
variant="outline"
className="workspace-create" className="workspace-create"
aria-label="デッキを作成" aria-label="デッキを作成"
title="デッキを作成" title="デッキを作成"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => { onClick={() => {
setCreating(!creating) onDialogChange('create')
setDeleting(false)
}} }}
> >
<Icon name="plus" /> <Icon name="plus" />
<span>デッキを作成</span> <span>デッキを作成</span>
</button> </Button>
<button <Button
variant="ghost"
className="workspace-delete" className="workspace-delete"
aria-label="デッキを削除" aria-label="デッキを削除"
title="デッキを削除" title="デッキを削除"
type="button" type="button"
disabled={!ready} disabled={!ready}
onClick={() => { onClick={() => {
setDeleting(!deleting) onDialogChange('delete')
setCreating(false)
}} }}
> >
<Icon name="trash" /> <Icon name="trash" />
<span>デッキを削除</span> <span>デッキを削除</span>
</button> </Button>
</div> </div>
{creating ? ( {dialog === 'create' ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}> <Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
<form <form
className="deck-rename" className="deck-rename"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault() event.preventDefault()
const title = String( const title = newTitle.trim()
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return if (!title) return
if (await onCreate(title)) setCreating(false) if (await onCreate(title)) onDialogChange(null)
}} }}
> >
<label htmlFor="new-deck-title"> <Label htmlFor="new-deck-title">
新しいデッキ名 新しいデッキ名
<input <Input
id="new-deck-title" id="new-deck-title"
name="title" name="title"
value={newTitle}
onChange={(event) => onNewTitleChange(event.target.value)}
maxLength={120} maxLength={120}
required required
/> />
</label> </Label>
<button type="submit">作成</button> <Button type="submit">作成</Button>
<button type="button" onClick={() => setCreating(false)}> <Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル キャンセル
</button> </Button>
</form> </form>
{errorNotice}
</Dialog> </Dialog>
) : null} ) : null}
{deleting ? ( {dialog === 'delete' ? (
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}> <Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
<div className="deck-undo"> <div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p> <p>このデッキとカラムの設定を削除します。</p>
<button <Button
variant="destructive"
type="button" type="button"
onClick={async () => { onClick={async () => {
if (await onDelete()) setDeleting(false) if (await onDelete()) onDialogChange(null)
}} }}
> >
削除する 削除する
</button> </Button>
<button type="button" onClick={() => setDeleting(false)}> <Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル キャンセル
</button> </Button>
</div> </div>
{errorNotice}
</Dialog> </Dialog>
) : null} ) : null}
</div> </div>
+14 -197
View File
@@ -1,47 +1,3 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.app-deck button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
min-height: 36px;
padding: var(--space-xs) var(--space-sm);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
color: var(--ink);
background: var(--surface);
cursor: pointer;
white-space: nowrap;
}
.app-deck button:hover:not(:disabled) {
background: var(--color-paper-3);
}
.app-deck button:active:not(:disabled) {
color: var(--accent);
}
.app-deck button:disabled {
opacity: 0.45;
cursor: default;
}
.app-deck .icon-button {
width: 36px;
height: 36px;
padding: var(--space-xs);
border-color: transparent;
background: transparent;
}
.app-deck .deck-primary {
color: var(--color-accent-ink);
background: var(--accent);
border-color: var(--accent);
font-weight: 650;
}
.app-deck .deck-primary:hover:not(:disabled),
.app-deck .deck-primary:active:not(:disabled) {
background: var(--accent-hover);
color: var(--color-accent-ink);
}
.deck-heading { .deck-heading {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -61,7 +17,7 @@
} }
.deck-heading p { .deck-heading p {
margin: var(--space-2xs) 0 0; margin: var(--space-2xs) 0 0;
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.deck-actions { .deck-actions {
@@ -73,7 +29,7 @@
.deck-help, .deck-help,
.deck-save-status, .deck-save-status,
.deck-column-status { .deck-column-status {
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
line-height: 1.5; line-height: 1.5;
} }
@@ -130,7 +86,7 @@
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: var(--space-xs); gap: var(--space-xs);
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.deck-column-label span { .deck-column-label span {
@@ -149,7 +105,7 @@
.deck-query { .deck-query {
margin: var(--space-2xs) 0; margin: var(--space-2xs) 0;
font-size: var(--text-xs); font-size: var(--text-xs);
color: var(--secondary); color: var(--muted-foreground);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -166,47 +122,9 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--control-border) var(--paper); scrollbar-color: var(--control-border) var(--paper);
} }
.column-menu {
position: relative;
}
.column-menu summary {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
list-style: none;
cursor: pointer;
}
.column-menu summary::-webkit-details-marker {
display: none;
}
.column-menu summary:hover,
.column-menu[open] summary {
background: var(--color-paper-3);
}
.column-menu-panel {
position: absolute;
top: 100%;
right: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 158px;
padding: var(--space-2xs);
border: var(--rule-hairline) solid var(--control-border);
background: var(--surface);
box-shadow: 0 var(--space-xs) var(--space-lg) var(--color-shadow);
}
.column-menu-panel button {
justify-content: flex-start;
border: 0;
}
.deck-message { .deck-message {
padding: var(--space-md); padding: var(--space-md);
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: 1.6; line-height: 1.6;
} }
@@ -223,11 +141,6 @@
.research-post header > div { .research-post header > div {
min-width: 0; min-width: 0;
} }
.research-post header img {
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong { .research-post strong {
font-size: var(--text-sm); font-size: var(--text-sm);
} }
@@ -250,11 +163,11 @@
justify-content: space-between; justify-content: space-between;
gap: var(--space-xs); gap: var(--space-xs);
margin-top: var(--space-sm); margin-top: var(--space-sm);
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.research-post a { .research-post a {
color: var(--accent); color: var(--primary);
text-underline-offset: 0.2em; text-underline-offset: 0.2em;
} }
.research-media { .research-media {
@@ -265,34 +178,6 @@
margin-block: var(--space-xs); margin-block: var(--space-xs);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.deck-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: var(--space-lg);
overflow-y: auto;
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-md);
background: var(--paper);
color: var(--ink);
box-shadow: 0 var(--space-md) var(--space-xl) var(--color-shadow);
}
.deck-dialog::backdrop {
background: var(--color-overlay);
}
.dialog-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
margin-bottom: var(--space-md);
}
.dialog-heading h2,
.deck-editor h2,
.deck-empty h2 {
margin: 0;
font-size: var(--text-md);
overflow-wrap: anywhere;
}
.deck-editor { .deck-editor {
margin: 0; margin: 0;
padding: 0; padding: 0;
@@ -312,30 +197,12 @@
font-size: var(--text-sm); font-size: var(--text-sm);
min-width: 0; min-width: 0;
} }
.deck-editor input:not([type="checkbox"]),
.deck-editor select,
.deck-rename input,
.deck-workspaces select {
min-width: 0;
width: 100%;
min-height: 40px;
padding: var(--space-xs);
border: var(--rule-hairline) solid var(--control-border);
border-radius: var(--radius-sm);
background: var(--canvas);
color: var(--ink);
}
.deck-editor .deck-checkbox { .deck-editor .deck-checkbox {
display: flex; display: flex;
align-items: center; align-items: center;
min-height: 40px; min-height: 40px;
margin-bottom: var(--space-sm); margin-bottom: var(--space-sm);
} }
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename { .deck-rename {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -347,7 +214,7 @@
flex: 1 1 100%; flex: 1 1 100%;
} }
.deck-error { .deck-error {
color: var(--error); color: var(--destructive);
font-size: var(--text-sm); font-size: var(--text-sm);
line-height: 1.6; line-height: 1.6;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -374,7 +241,7 @@
padding: var(--space-lg); padding: var(--space-lg);
} }
.deck-empty p { .deck-empty p {
color: var(--secondary); color: var(--muted-foreground);
line-height: 1.7; line-height: 1.7;
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
@@ -386,10 +253,10 @@
} }
.deck-workspaces > .deck-actions > label { .deck-workspaces > .deck-actions > label {
width: 100%; width: 100%;
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.deck-workspaces > .deck-actions > select { .deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
flex: 1 1 100%; flex: 1 1 100%;
} }
.deck-workspaces > .deck-actions > button { .deck-workspaces > .deck-actions > button {
@@ -404,35 +271,6 @@
width: 100%; width: 100%;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.deck-workspaces {
flex: 1;
}
.deck-workspaces > .deck-actions {
flex-wrap: nowrap;
gap: var(--space-2xs);
}
.deck-workspaces > .deck-actions > label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.deck-workspaces > .deck-actions > select {
flex: 1;
width: 0;
}
.deck-workspaces > .deck-actions > button {
flex: none;
}
.app-deck .sidebar-add {
width: 36px;
flex-shrink: 0;
padding: var(--space-xs);
}
.sidebar-add span {
display: none;
}
.deck-heading { .deck-heading {
min-height: 52px; min-height: 52px;
padding-inline: var(--space-sm); padding-inline: var(--space-sm);
@@ -444,37 +282,19 @@
grid-auto-columns: min(328px, 100%); grid-auto-columns: min(328px, 100%);
} }
} }
@media (pointer: coarse) {
.app-deck .icon-button,
.column-menu summary {
min-width: 40px;
min-height: 40px;
}
}
.deck-count { .deck-count {
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.research-post header time { .research-post header time {
display: block; display: block;
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
.deck-column-status { .deck-column-status {
text-align: right; text-align: right;
} }
@media (max-width: 767px) { .research-post header > div:not([data-slot="avatar"]) {
.workspace-create span,
.workspace-delete span {
display: none;
}
.app-deck .workspace-create,
.app-deck .workspace-delete {
width: 36px;
padding: var(--space-xs);
}
}
.research-post header > div {
flex: 1; flex: 1;
} }
.research-post header time { .research-post header time {
@@ -490,6 +310,3 @@
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
.column-menu-panel .danger {
color: var(--error);
}
+38 -24
View File
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from '@tanstack/react-start'
import { type ReactElement, useId } from 'react' import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { loadMastodonLists } from '../platforms/mastodon-server-functions' import { loadMastodonLists } from '../platforms/mastodon-server-functions'
import type { MastodonSource } from '../platforms/mastodon-source' import type { MastodonSource } from '../platforms/mastodon-source'
@@ -32,9 +36,10 @@ export function MastodonSourceEditor({
const id = useId() const id = useId()
return ( return (
<> <>
<label htmlFor={id}> <Label htmlFor={id}>
カラムの種類 カラムの種類
<select <NativeSelect
className="w-full"
id={id} id={id}
value={source.kind} value={source.kind}
onChange={(event) => onChange={(event) =>
@@ -45,12 +50,12 @@ export function MastodonSourceEditor({
) )
} }
> >
<option value="search">検索</option> <NativeSelectOption value="search">検索</NativeSelectOption>
<option value="user">ユーザー投稿</option> <NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<option value="list">リスト</option> <NativeSelectOption value="list">リスト</NativeSelectOption>
<option value="hashtag">ハッシュタグ</option> <NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
</select> </NativeSelect>
</label> </Label>
<MastodonSourceFields <MastodonSourceFields
source={source} source={source}
connectionId={connectionId} connectionId={connectionId}
@@ -90,9 +95,9 @@ function MastodonSearchEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={id}> <Label htmlFor={id}>
Mastodonの検索条件 Mastodonの検索条件
<input <Input
id={id} id={id}
value={source.query} value={source.query}
onChange={(event) => onChange={(event) =>
@@ -102,7 +107,7 @@ function MastodonSearchEditor({
placeholder="例:WebMCP" placeholder="例:WebMCP"
required required
/> />
</label> </Label>
<p className="deck-help"> <p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。 全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
</p> </p>
@@ -117,9 +122,9 @@ function MastodonUserEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={id}> <Label htmlFor={id}>
ユーザー ユーザー
<input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
@@ -129,7 +134,7 @@ function MastodonUserEditor({
maxLength={256} maxLength={256}
required required
/> />
</label> </Label>
</div> </div>
) )
} }
@@ -141,9 +146,9 @@ function MastodonHashtagEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={id}> <Label htmlFor={id}>
ハッシュタグ ハッシュタグ
<input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
@@ -153,7 +158,7 @@ function MastodonHashtagEditor({
maxLength={128} maxLength={128}
required required
/> />
</label> </Label>
</div> </div>
) )
} }
@@ -168,9 +173,9 @@ function MastodonListEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={id}> <Label htmlFor={id}>
リスト リスト
<input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
@@ -180,7 +185,7 @@ function MastodonListEditor({
maxLength={32} maxLength={32}
required required
/> />
</label> </Label>
{connectionId && ( {connectionId && (
<MastodonListChoices <MastodonListChoices
connectionId={connectionId} connectionId={connectionId}
@@ -209,9 +214,13 @@ function MastodonListChoices({
return ( return (
<p role="alert"> <p role="alert">
リストを取得できませんでした。 リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}> <Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行 再試行
</button> </Button>
</p> </p>
) )
return ( return (
@@ -220,9 +229,14 @@ function MastodonListChoices({
<p>このアカウントのリストはありません。</p> <p>このアカウントのリストはありません。</p>
) : ( ) : (
lists.data.lists.map((list) => ( lists.data.lists.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}> <Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
{list.name} {list.name}
</button> </Button>
)) ))
)} )}
</section> </section>
+14 -12
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Icon } from '#/components/icon' import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text' import { PostText } from '#/components/post-text'
import { Avatar, AvatarFallback, AvatarImage } from '#/components/ui/avatar'
import { Badge } from '#/components/ui/badge'
import { Button } from '#/components/ui/button'
import type { ResearchPost } from '#/features/platforms/types' import type { ResearchPost } from '#/features/platforms/types'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', { const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
@@ -49,20 +52,17 @@ export function ResearchPostCard({
className={`research-post${highlighted ? ' citation-highlight' : ''}`} className={`research-post${highlighted ? ' citation-highlight' : ''}`}
data-post-url={post.url} data-post-url={post.url}
> >
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null} {citationSnapshot ? (
<Badge variant="secondary">調査時に取得した投稿</Badge>
) : null}
{post.boostedBy ? ( {post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p> <p className="handle">{post.boostedBy.name} がブースト</p>
) : null} ) : null}
<header> <header>
{post.author.avatarUrl ? ( <Avatar className="size-9 shrink-0">
<img <AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
src={post.author.avatarUrl} <AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
alt="" </Avatar>
width={36}
height={36}
loading="lazy"
/>
) : null}
<div> <div>
<strong>{post.author.name}</strong> <strong>{post.author.name}</strong>
<span className="handle"> <span className="handle">
@@ -78,13 +78,15 @@ export function ResearchPostCard({
{concealed ? ( {concealed ? (
<div> <div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p> <p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<button <Button
variant="outline"
size="sm"
type="button" type="button"
aria-expanded={revealed} aria-expanded={revealed}
onClick={() => setRevealed(!revealed)} onClick={() => setRevealed(!revealed)}
> >
{revealed ? '内容を隠す' : '内容を表示'} {revealed ? '内容を隠す' : '内容を表示'}
</button> </Button>
</div> </div>
) : null} ) : null}
{!concealed || revealed ? ( {!concealed || revealed ? (
+47 -32
View File
@@ -1,4 +1,9 @@
import { type ReactElement, useId } from 'react' import { type ReactElement, useId } from 'react'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
import { Label } from '#/components/ui/label'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { useListChoices } from '../posts/use-list-choices' import { useListChoices } from '../posts/use-list-choices'
import type { DeckColumn } from './model' import type { DeckColumn } from './model'
@@ -33,20 +38,21 @@ export function TwitterSourceEditor({
const id = useId() const id = useId()
return ( return (
<> <>
<label htmlFor={id}> <Label htmlFor={id}>
カラムの種類 カラムの種類
<select <NativeSelect
className="w-full"
id={id} id={id}
value={source.kind} value={source.kind}
onChange={(event) => onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind'])) onChange(defaultTwitterSource(event.target.value as Source['kind']))
} }
> >
<option value="search">検索</option> <NativeSelectOption value="search">検索</NativeSelectOption>
<option value="user">ユーザー投稿</option> <NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<option value="list">リスト</option> <NativeSelectOption value="list">リスト</NativeSelectOption>
</select> </NativeSelect>
</label> </Label>
<TwitterSourceFields <TwitterSourceFields
source={source} source={source}
connectionId={connectionId} connectionId={connectionId}
@@ -84,9 +90,9 @@ function TwitterSearchEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={`${id}-query`}> <Label htmlFor={`${id}-query`}>
Twitterの検索条件 Twitterの検索条件
<input <Input
id={`${id}-query`} id={`${id}-query`}
value={source.query} value={source.query}
onChange={(event) => onChange={(event) =>
@@ -96,10 +102,11 @@ function TwitterSearchEditor({
placeholder="例:WebMCP lang:ja" placeholder="例:WebMCP lang:ja"
required required
/> />
</label> </Label>
<label htmlFor={`${id}-product`}> <Label htmlFor={`${id}-product`}>
表示順 表示順
<select <NativeSelect
className="w-full"
id={`${id}-product`} id={`${id}-product`}
value={source.product} value={source.product}
onChange={(event) => onChange={(event) =>
@@ -109,23 +116,22 @@ function TwitterSearchEditor({
}) })
} }
> >
<option value="Latest">最新</option> <NativeSelectOption value="Latest">最新</NativeSelectOption>
<option value="Top">話題</option> <NativeSelectOption value="Top">話題</NativeSelectOption>
</select> </NativeSelect>
</label> </Label>
<p className="deck-help"> <p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。 from:、lang:、since: など、Twitterの検索構文を使えます。
</p> </p>
<label className="deck-checkbox"> <Label className="deck-checkbox">
<input <Checkbox
type="checkbox"
checked={source.following} checked={source.following}
onChange={(event) => onCheckedChange={(checked) =>
onChange({ ...source, following: event.target.checked }) onChange({ ...source, following: checked })
} }
/> />
フォロー中のみ フォロー中のみ
</label> </Label>
</div> </div>
) )
} }
@@ -137,9 +143,9 @@ function TwitterUserEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={id}> <Label htmlFor={id}>
ユーザー ユーザー
<input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
@@ -149,7 +155,7 @@ function TwitterUserEditor({
maxLength={256} maxLength={256}
required required
/> />
</label> </Label>
</div> </div>
) )
} }
@@ -162,9 +168,9 @@ function TwitterListEditor({
const id = useId() const id = useId()
return ( return (
<div className="deck-source-fields"> <div className="deck-source-fields">
<label htmlFor={id}> <Label htmlFor={id}>
リスト リスト
<input <Input
id={id} id={id}
value={source.target} value={source.target}
onChange={(event) => onChange={(event) =>
@@ -174,7 +180,7 @@ function TwitterListEditor({
maxLength={256} maxLength={256}
required required
/> />
</label> </Label>
{connectionId && ( {connectionId && (
<TwitterListChoices <TwitterListChoices
connectionId={connectionId} connectionId={connectionId}
@@ -198,9 +204,13 @@ function TwitterListChoices({
return ( return (
<p role="alert"> <p role="alert">
リストを取得できませんでした。 リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}> <Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行 再試行
</button> </Button>
</p> </p>
) )
return ( return (
@@ -212,10 +222,15 @@ function TwitterListChoices({
<p>リストはありません。URLまたはIDでも指定できます。</p> <p>リストはありません。URLまたはIDでも指定できます。</p>
) : ( ) : (
lists.data.map((list) => ( lists.data.map((list) => (
<button type="button" key={list.id} onClick={() => onSelect(list.id)}> <Button
variant="outline"
type="button"
key={list.id}
onClick={() => onSelect(list.id)}
>
{list.name} {list.name}
{list.isPrivate ? ' · 非公開' : ''} {list.isPrivate ? ' · 非公開' : ''}
</button> </Button>
)) ))
)} )}
</section> </section>
@@ -1,10 +1,10 @@
.research-post.citation-highlight { .research-post.citation-highlight {
outline: 2px solid var(--accent, #67b7ff); outline: 2px solid var(--primary);
outline-offset: -2px; outline-offset: -2px;
background-color: color-mix(in srgb, var(--accent, #67b7ff) 14%, transparent); background-color: color-mix(in srgb, var(--primary) 14%, transparent);
} }
.research-post:focus-visible { .research-post:focus-visible {
outline: 2px solid var(--accent, #67b7ff); outline: 2px solid var(--primary);
outline-offset: -2px; outline-offset: -2px;
} }
+26 -123
View File
@@ -1,16 +1,14 @@
.app.app-deck {
grid-template-columns: 340px minmax(0, 1fr);
}
.research-panel { .research-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
border-right: 1px solid var(--border); background: var(--background);
background: var(--paper);
overflow: hidden; overflow: hidden;
} }
.research-heading { .research-heading {
flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -23,13 +21,11 @@
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
} }
.research-heading button {
font-size: 0.75rem;
}
.research-context { .research-context {
margin: 0.5rem 0 0; margin: 0 0 0.75rem;
color: var(--secondary); color: var(--muted-foreground);
font-size: 0.75rem; font-size: 0.75rem;
overflow-wrap: anywhere;
} }
.research-history { .research-history {
display: flex; display: flex;
@@ -38,20 +34,14 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--secondary); color: var(--muted-foreground);
} }
.research-history span { .research-history span {
flex-shrink: 0; flex-shrink: 0;
} }
.research-history select { .research-history > [data-slot="native-select-wrapper"] {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 0.75rem;
color: var(--ink);
background: var(--canvas);
border: 1px solid var(--control-border);
border-radius: 0.25rem;
padding: 0.4rem;
} }
.research-panel > p { .research-panel > p {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@@ -61,45 +51,32 @@
.research-conversation { .research-conversation {
flex: 1; flex: 1;
min-height: 80px; min-height: 80px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem;
} }
.research-empty { .research-empty {
color: var(--secondary); color: var(--muted-foreground);
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.8; line-height: 1.8;
} }
.research-message { .research-message {
margin-bottom: 1.25rem;
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.65; line-height: 1.65;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.research-message strong {
font-size: 0.7rem;
color: var(--secondary);
}
.research-message p { .research-message p {
margin: 0.35rem 0 0; margin: 0.35rem 0 0;
white-space: pre-wrap; white-space: pre-wrap;
} }
.research-message-user { .research-tool-message {
border-left: 2px solid var(--accent); color: var(--muted-foreground);
padding-left: 0.75rem;
}
.research-message-tool {
color: var(--secondary);
font-size: 0.75rem; font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
} }
.research-start-form { .research-start-form {
flex-shrink: 0;
padding: 0.75rem; padding: 0.75rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.research-context {
margin: 0 0 0.75rem;
overflow-wrap: anywhere;
}
.research-start-form > fieldset { .research-start-form > fieldset {
border: 0; border: 0;
margin: 0; margin: 0;
@@ -111,21 +88,9 @@
.research-start-form label { .research-start-form label {
font-size: 0.8rem; font-size: 0.8rem;
} }
.research-start-form textarea {
width: 100%;
resize: vertical;
color: var(--ink);
background: var(--canvas);
border: 1px solid var(--control-border);
border-radius: 0.25rem;
padding: 0.6rem;
}
.research-account-picker { .research-account-picker {
font-size: 0.75rem; font-size: 0.75rem;
} }
.research-account-picker summary {
cursor: pointer;
}
.research-accounts { .research-accounts {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
@@ -141,10 +106,8 @@
align-items: center; align-items: center;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.research-accounts input {
width: auto;
}
.research-run { .research-run {
flex-shrink: 0;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.75rem; font-size: 0.75rem;
@@ -159,73 +122,6 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
font-size: 0.75rem; font-size: 0.75rem;
} }
.deck-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.deck-toolbar .deck-workspaces {
flex: 1;
}
.deck-toolbar .deck-workspaces > .deck-actions {
flex-wrap: nowrap;
}
.deck-toolbar .deck-workspaces > .deck-actions > label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.deck-toolbar .deck-workspaces > .deck-actions > select {
flex: 1;
width: 0;
}
.deck-toolbar .deck-workspaces > .deck-actions > button {
flex: none;
width: 36px;
padding: 0.25rem;
}
.deck-toolbar .workspace-create span,
.deck-toolbar .workspace-delete span,
.deck-toolbar .sidebar-add span,
.deck-toolbar .sidebar-note {
display: none;
}
.app-deck .deck-toolbar .sidebar-add {
width: 36px;
}
@media (max-width: 767px) {
.app.app-deck {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 44dvh minmax(0, 1fr);
}
.research-panel {
border-right: 0;
border-bottom: 1px solid var(--border);
overflow-y: auto;
}
.research-heading {
padding: 0.5rem 0.75rem;
}
.research-conversation {
flex: 1 0 80px;
min-height: 80px;
}
.research-start-form {
padding: 0.5rem 0.75rem;
}
.research-run {
flex-shrink: 0;
}
.deck-toolbar > button:last-child {
width: 100%;
}
}
.research-markdown { .research-markdown {
min-width: 0; min-width: 0;
overflow-wrap: anywhere; overflow-wrap: anywhere;
@@ -238,26 +134,33 @@
.research-markdown h5, .research-markdown h5,
.research-markdown h6 { .research-markdown h6 {
font-size: 1em; font-size: 1em;
font-weight: 600;
margin: 1em 0 0.4em; margin: 1em 0 0.4em;
} }
.research-markdown ul, .research-markdown ul,
.research-markdown ol { .research-markdown ol {
padding-left: 1.4rem; padding-left: 1.4rem;
} }
.research-markdown ul {
list-style: disc;
}
.research-markdown ol {
list-style: decimal;
}
.research-markdown li + li { .research-markdown li + li {
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.research-markdown blockquote { .research-markdown blockquote {
margin: 0.75rem 0; margin: 0.75rem 0;
padding-left: 0.75rem; padding-left: 0.75rem;
border-left: 2px solid var(--control-border); border-left: 2px solid var(--border);
color: var(--secondary); color: var(--muted-foreground);
} }
.research-markdown pre { .research-markdown pre {
max-width: 100%; max-width: 100%;
overflow-x: auto; overflow-x: auto;
padding: 0.75rem; padding: 0.75rem;
background: var(--canvas); background: var(--muted);
white-space: pre; white-space: pre;
font-size: 0.75rem; font-size: 0.75rem;
} }
@@ -281,7 +184,7 @@
text-align: left; text-align: left;
} }
.research-markdown a { .research-markdown a {
color: var(--accent); color: var(--primary);
text-decoration: underline; text-decoration: underline;
text-underline-offset: 0.15em; text-underline-offset: 0.15em;
} }
+41 -10
View File
@@ -83,6 +83,7 @@ function show(connections = [connection], ready = true) {
const ui = ( const ui = (
ready = true, ready = true,
contextDeck = { id: 'manual', title: '手動デッキ', columns: [] }, contextDeck = { id: 'manual', title: '手動デッキ', columns: [] },
presentation = 'desktop',
) => ( ) => (
<QueryClientProvider client={client}> <QueryClientProvider client={client}>
<ResearchPanel <ResearchPanel
@@ -91,16 +92,23 @@ function show(connections = [connection], ready = true) {
connections={connections} connections={connections}
onOpenDeck={onOpenDeck} onOpenDeck={onOpenDeck}
onNavigateCitation={onNavigateCitation} onNavigateCitation={onNavigateCitation}
/> >
{(renderPanel) => <div key={presentation}>{renderPanel()}</div>}
</ResearchPanel>
</QueryClientProvider> </QueryClientProvider>
) )
return { ...render(ui(ready)), client, ui, onOpenDeck, onNavigateCitation } return { ...render(ui(ready)), client, ui, onOpenDeck, onNavigateCitation }
} }
it('retains input and uses the same request ID when retrying a failed start', async () => { it('retains input, selected accounts, and the request ID across responsive view remounts after a failed start', async () => {
actions.start.mockRejectedValue(new Error('internal credential detail')) actions.start.mockRejectedValue(new Error('internal credential detail'))
show() const view = show([
connection,
{ ...connection, id: 'second', displayName: 'second' },
])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled()) await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 2件' }))
fireEvent.click(screen.getByRole('checkbox', { name: 'second · Twitter' }))
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: 'WebMCPの反応' }, target: { value: 'WebMCPの反応' },
}) })
@@ -110,6 +118,15 @@ it('retains input and uses the same request ID when retrying a failed start', as
) )
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応') expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
expect(screen.queryByText(/internal credential/)).toBeNull() expect(screen.queryByText(/internal credential/)).toBeNull()
view.rerender(view.ui(true, undefined, 'mobile'))
expect(screen.getByRole('alert')).toHaveTextContent('入力内容は残っています')
expect(screen.getByLabelText('メッセージ')).toHaveValue('WebMCPの反応')
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 1件' }))
expect(
screen.getByRole('checkbox', { name: 'second · Twitter' }),
).not.toBeChecked()
expect(MockEventSource.instances).toHaveLength(1)
expect(MockEventSource.instances[0]?.close).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '送信' })) fireEvent.click(screen.getByRole('button', { name: '送信' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2)) await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
expect(actions.start.mock.calls[1]?.[0]).toEqual( expect(actions.start.mock.calls[1]?.[0]).toEqual(
@@ -197,9 +214,13 @@ it('limits account selection to six and shows the host report path on completion
) )
await screen.findByText('/home/owner/reports/webmcp.md') await screen.findByText('/home/owner/reports/webmcp.md')
fireEvent.click(screen.getByText('接続アカウント · 6件')) fireEvent.click(screen.getByText('接続アカウント · 6件'))
expect(screen.getByLabelText('account6 · Twitter')).toBeDisabled() expect(
fireEvent.click(screen.getByLabelText('account0 · Twitter')) screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
expect(screen.getByLabelText('account6 · Twitter')).toBeEnabled() ).toHaveAttribute('aria-disabled', 'true')
fireEvent.click(screen.getByRole('checkbox', { name: 'account0 · Twitter' }))
expect(
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
).not.toHaveAttribute('aria-disabled', 'true')
}) })
it('waits for the workspace before restoring a completed plan from the stream', async () => { it('waits for the workspace before restoring a completed plan from the stream', async () => {
@@ -369,6 +390,8 @@ it('shows streamed conversation and sends follow-up input with the current deck
it('selects newly loaded connected accounts until the user changes their selection', async () => { it('selects newly loaded connected accounts until the user changes their selection', async () => {
const view = show([]) const view = show([])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' }))
await screen.findByText('接続アカウントを先に追加してください。') await screen.findByText('接続アカウントを先に追加してください。')
view.rerender( view.rerender(
<QueryClientProvider client={view.client}> <QueryClientProvider client={view.client}>
@@ -595,9 +618,15 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
expect(screen.getByLabelText('メッセージ')).toHaveValue('') expect(screen.getByLabelText('メッセージ')).toHaveValue('')
expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible() expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible()
fireEvent.click(screen.getByText('接続アカウント · 1件')) fireEvent.click(screen.getByText('接続アカウント · 1件'))
expect(screen.getByLabelText('調査用アカウント · Twitter')).not.toBeChecked() expect(
expect(screen.getByLabelText('account2 · Twitter')).toBeChecked() screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
fireEvent.click(screen.getByLabelText('調査用アカウント · Twitter')) ).not.toBeChecked()
expect(
screen.getByRole('checkbox', { name: 'account2 · Twitter' }),
).toBeChecked()
fireEvent.click(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
)
act(() => act(() =>
MockEventSource.instances[0]?.onmessage?.({ MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({ data: JSON.stringify({
@@ -612,7 +641,9 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
run: { message: '更新' }, run: { message: '更新' },
}), }),
) )
expect(screen.getByLabelText('調査用アカウント · Twitter')).toBeChecked() expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).toBeChecked()
expect(view.onOpenDeck).toHaveBeenCalledOnce() expect(view.onOpenDeck).toHaveBeenCalledOnce()
fireEvent.change(screen.getByLabelText('メッセージ'), { fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: '続きを調べて' }, target: { value: '続きを調べて' },
+176 -78
View File
@@ -1,6 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start' import { useServerFn } from '@tanstack/react-start'
import { useCallback, useEffect, useId, useRef, useState } from 'react' import {
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react'
import { Bubble, BubbleContent } from '#/components/ui/bubble'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '#/components/ui/collapsible'
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '#/components/ui/input-group'
import { Marker, MarkerContent } from '#/components/ui/marker'
import { Message, MessageContent, MessageHeader } from '#/components/ui/message'
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from '#/components/ui/message-scroller'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import type { Connection } from '../connections/model' import type { Connection } from '../connections/model'
import type { Deck } from '../decks/model' import type { Deck } from '../decks/model'
import { import {
@@ -28,6 +60,12 @@ const statusNames: Record<ResearchRun['status'], string> = {
interrupted: '中断しました・続きから再開できます', interrupted: '中断しました・続きから再開できます',
} }
type ResearchInput = { topic: string; connectionIds: string[] } type ResearchInput = { topic: string; connectionIds: string[] }
type ResearchDraft = {
runId: string | null
topic: string
selected: string[] | null
}
type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode
export function ResearchPanel({ export function ResearchPanel({
ready, ready,
@@ -35,12 +73,14 @@ export function ResearchPanel({
connections, connections,
onOpenDeck, onOpenDeck,
onNavigateCitation, onNavigateCitation,
children,
}: { }: {
ready: boolean ready: boolean
contextDeck: Deck contextDeck: Deck
connections: Connection[] connections: Connection[]
onOpenDeck: (deck: Deck, activate?: boolean) => unknown onOpenDeck: (deck: Deck, activate?: boolean) => unknown
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
children?: (renderPanel: RenderResearchPanel) => ReactNode
}) { }) {
const fetchStatus = useServerFn(loadResearchStatus) const fetchStatus = useServerFn(loadResearchStatus)
const start = useServerFn(startResearch) const start = useServerFn(startResearch)
@@ -48,16 +88,33 @@ export function ResearchPanel({
const reset = useServerFn(resetResearch) const reset = useServerFn(resetResearch)
const select = useServerFn(selectResearch) const select = useServerFn(selectResearch)
const client = useQueryClient() const client = useQueryClient()
const historyId = useId()
const status = useQuery({ const status = useQuery({
queryKey: ['research-status'], queryKey: ['research-status'],
queryFn: () => fetchStatus(), queryFn: () => fetchStatus(),
retry: false, retry: false,
}) })
const run = status.data?.run ?? null const run = status.data?.run ?? null
const runId = run?.id ?? null
const [draft, setDraft] = useState<ResearchDraft>({
runId,
topic: '',
selected: run?.connectionIds ?? null,
})
// Conversation changes reset the draft; responsive view remounts do not.
if (draft.runId !== runId) {
setDraft({ runId, topic: '', selected: run?.connectionIds ?? null })
}
function updateDraft(
update: Partial<Pick<ResearchDraft, 'topic' | 'selected'>>,
) {
setDraft((current) =>
current.runId === runId ? { ...current, ...update } : current,
)
}
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [reconnecting, setReconnecting] = useState(false) const [reconnecting, setReconnecting] = useState(false)
const [draftResetVersion, setDraftResetVersion] = useState(0)
const previousRunId = useRef<string | null>(null) const previousRunId = useRef<string | null>(null)
const syncedRunId = useRef<string | null>(null) const syncedRunId = useRef<string | null>(null)
const attemptedVersion = useRef<string | null>(null) const attemptedVersion = useRef<string | null>(null)
@@ -79,7 +136,6 @@ export function ResearchPanel({
useEffect(() => { useEffect(() => {
if (previousRunId.current && previousRunId.current !== run?.id) { if (previousRunId.current && previousRunId.current !== run?.id) {
setDraftResetVersion((value) => value + 1)
request.current = null request.current = null
pendingTurn.current = null pendingTurn.current = null
} }
@@ -209,7 +265,7 @@ export function ResearchPanel({
) )
request.current = null request.current = null
pendingTurn.current = null pendingTurn.current = null
setDraftResetVersion((value) => value + 1) updateDraft({ topic: '' })
} catch { } catch {
setError( setError(
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。', '新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
@@ -252,11 +308,13 @@ export function ResearchPanel({
} }
} }
return ( const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside className="research-panel" aria-label="調査チャット"> <aside className="research-panel" aria-label="調査チャット">
<header className="research-heading"> <header className="research-heading">
<h2>調査チャット</h2> <h2>調査チャット</h2>
<button <Button
variant="ghost"
size="sm"
type="button" type="button"
disabled={busy || !run || isResearchActive(run)} disabled={busy || !run || isResearchActive(run)}
title={ title={
@@ -267,24 +325,26 @@ export function ResearchPanel({
onClick={() => void newChat()} onClick={() => void newChat()}
> >
新しいチャット 新しいチャット
</button> </Button>
{(status.data?.history?.length ?? 0) > 0 && ( {(status.data?.history?.length ?? 0) > 0 && (
<label className="research-history"> <label className="research-history" htmlFor={historyId}>
<span>チャット履歴</span> <span>チャット履歴</span>
<select <NativeSelect
id={historyId}
size="sm"
value={run?.id ?? ''} value={run?.id ?? ''}
disabled={busy || isResearchActive(run)} disabled={busy || isResearchActive(run)}
onChange={(event) => void selectChat(event.target.value)} onChange={(event) => void selectChat(event.target.value)}
> >
<option value="" disabled> <NativeSelectOption value="" disabled>
新しいチャット 新しいチャット
</option> </NativeSelectOption>
{status.data?.history?.map((entry) => ( {status.data?.history?.map((entry) => (
<option key={entry.id} value={entry.id}> <NativeSelectOption key={entry.id} value={entry.id}>
{entry.title} {entry.title}
</option> </NativeSelectOption>
))} ))}
</select> </NativeSelect>
</label> </label>
)} )}
</header> </header>
@@ -296,9 +356,9 @@ export function ResearchPanel({
{status.isError && ( {status.isError && (
<p className="deck-error" role="alert"> <p className="deck-error" role="alert">
調査状況を取得できませんでした。 調査状況を取得できませんでした。
<button type="button" onClick={() => void status.refetch()}> <Button type="button" onClick={() => void status.refetch()}>
再試行 再試行
</button> </Button>
</p> </p>
)} )}
{error && ( {error && (
@@ -315,7 +375,11 @@ export function ResearchPanel({
key={`conversation:${run?.id ?? 'new'}`} key={`conversation:${run?.id ?? 'new'}`}
messages={run?.messages ?? []} messages={run?.messages ?? []}
citations={run?.citations ?? []} citations={run?.citations ?? []}
onNavigateCitation={onNavigateCitation} onNavigateCitation={(url, citations) => {
const navigated = onNavigateCitation?.(url, citations) ?? false
if (navigated) onCitationNavigated?.()
return navigated
}}
/> />
{run && ( {run && (
<ResearchRunStatus <ResearchRunStatus
@@ -327,8 +391,10 @@ export function ResearchPanel({
)} )}
<ResearchComposer <ResearchComposer
key={run?.id ?? 'new'} key={run?.id ?? 'new'}
initialConnectionIds={run?.connectionIds} topic={draft.topic}
resetVersion={draftResetVersion} selected={draft.selected}
onTopicChange={(topic) => updateDraft({ topic })}
onSelectionChange={(selected) => updateDraft({ selected })}
connections={connections} connections={connections}
contextTitle={contextDeck.title} contextTitle={contextDeck.title}
disabled={ disabled={
@@ -338,6 +404,7 @@ export function ResearchPanel({
/> />
</aside> </aside>
) )
return children ? children(renderPanel) : renderPanel()
} }
function ResearchConversation({ function ResearchConversation({
@@ -349,39 +416,39 @@ function ResearchConversation({
citations: ResearchCitation[] citations: ResearchCitation[]
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
}) { }) {
const container = useRef<HTMLDivElement>(null)
const follow = useRef(true)
useEffect(() => {
const element = container.current
if (element && follow.current && messages.length > 0)
element.scrollTop = element.scrollHeight
}, [messages])
const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' } const names = { user: 'あなた', assistant: 'Codex', tool: 'ツール実行' }
return ( return (
<div <MessageScrollerProvider autoScroll>
className="research-conversation" <MessageScroller className="research-conversation">
role="log" <MessageScrollerViewport aria-label="調査メッセージ">
aria-label="調査の会話" <MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4">
aria-live="polite"
ref={container}
onScroll={() => {
const element = container.current
if (element)
follow.current =
element.scrollHeight - element.scrollTop - element.clientHeight < 60
}}
>
{messages.length === 0 ? ( {messages.length === 0 ? (
<MessageScrollerItem messageId="empty">
<p className="research-empty"> <p className="research-empty">
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。 気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
</p> </p>
</MessageScrollerItem>
) : ( ) : (
messages.map((message) => ( messages.map((message) => (
<article <MessageScrollerItem
className={`research-message research-message-${message.role}`}
key={message.id} key={message.id}
messageId={message.id}
scrollAnchor={message.role === 'user'}
> >
<strong>{names[message.role]}</strong> {message.role === 'tool' ? (
<Marker className="research-tool-message">
<MarkerContent>
<span className="sr-only">{names.tool}: </span>
{message.text}
</MarkerContent>
</Marker>
) : (
<Message
align={message.role === 'user' ? 'end' : 'start'}
className="research-message"
>
<MessageContent>
<MessageHeader>{names[message.role]}</MessageHeader>
{message.role === 'assistant' ? ( {message.role === 'assistant' ? (
<ResearchMarkdown <ResearchMarkdown
text={message.text} text={message.text}
@@ -390,25 +457,40 @@ function ResearchConversation({
} }
/> />
) : ( ) : (
<Bubble align="end">
<BubbleContent>
<p>{message.text}</p> <p>{message.text}</p>
</BubbleContent>
</Bubble>
)} )}
</article> </MessageContent>
</Message>
)}
</MessageScrollerItem>
)) ))
)} )}
</div> </MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton aria-label="最新のメッセージへ" />
</MessageScroller>
</MessageScrollerProvider>
) )
} }
function ResearchComposer({ function ResearchComposer({
initialConnectionIds, topic,
resetVersion, selected,
onTopicChange,
onSelectionChange,
connections, connections,
contextTitle, contextTitle,
disabled, disabled,
onSend, onSend,
}: { }: {
initialConnectionIds?: string[] topic: string
resetVersion: number selected: string[] | null
onTopicChange: (topic: string) => void
onSelectionChange: (selected: string[]) => void
connections: Connection[] connections: Connection[]
contextTitle: string contextTitle: string
disabled: boolean disabled: boolean
@@ -417,13 +499,6 @@ function ResearchComposer({
const available = connections.filter( const available = connections.filter(
(connection) => connection.status === 'connected', (connection) => connection.status === 'connected',
) )
const [topic, setTopic] = useState('')
useEffect(() => {
if (resetVersion > 0) setTopic('')
}, [resetVersion])
const [selected, setSelected] = useState<string[] | null>(
initialConnectionIds ?? null,
)
const selectedIds = const selectedIds =
selected ?? available.slice(0, 6).map((connection) => connection.id) selected ?? available.slice(0, 6).map((connection) => connection.id)
const id = useId() const id = useId()
@@ -441,7 +516,7 @@ function ResearchComposer({
topic: topic.trim(), topic: topic.trim(),
connectionIds: validSelection, connectionIds: validSelection,
}).then((sent) => { }).then((sent) => {
if (sent) setTopic('') if (sent) onTopicChange('')
}) })
}} }}
> >
@@ -449,25 +524,29 @@ function ResearchComposer({
送信するデッキ: <strong>{contextTitle}</strong> 送信するデッキ: <strong>{contextTitle}</strong>
</p> </p>
<fieldset disabled={disabled}> <fieldset disabled={disabled}>
<details className="research-account-picker"> <Collapsible className="research-account-picker" disabled={disabled}>
<summary>接続アカウント · {validSelection.length}件</summary> <CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
接続アカウント · {validSelection.length}件
</CollapsibleTrigger>
<CollapsibleContent>
<fieldset className="research-accounts"> <fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend> <legend>調査に使う接続アカウント(最大6件)</legend>
{available.length === 0 ? ( {available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p> <p>接続アカウントを先に追加してください。</p>
) : ( ) : (
available.map((connection) => ( available.map((connection) => (
<label key={connection.id}> <label key={connection.id} htmlFor={`${id}-${connection.id}`}>
<input <Checkbox
type="checkbox" id={`${id}-${connection.id}`}
checked={selectedIds.includes(connection.id)} checked={selectedIds.includes(connection.id)}
disabled={ disabled={
!selectedIds.includes(connection.id) && disabled ||
validSelection.length >= 6 (!selectedIds.includes(connection.id) &&
validSelection.length >= 6)
} }
onChange={(event) => onCheckedChange={(checked) =>
setSelected( onSelectionChange(
event.target.checked checked
? [...selectedIds, connection.id] ? [...selectedIds, connection.id]
: selectedIds.filter( : selectedIds.filter(
(value) => value !== connection.id, (value) => value !== connection.id,
@@ -481,24 +560,33 @@ function ResearchComposer({
)) ))
)} )}
</fieldset> </fieldset>
</details> </CollapsibleContent>
</Collapsible>
<label htmlFor={id}>メッセージ</label> <label htmlFor={id}>メッセージ</label>
<textarea <InputGroup>
<InputGroupTextarea
id={id} id={id}
value={topic} value={topic}
onChange={(event) => setTopic(event.target.value)} onChange={(event) => onTopicChange(event.target.value)}
maxLength={1000} maxLength={1000}
rows={3} rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。" placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
required required
disabled={disabled}
/> />
<button <InputGroupAddon align="block-end" className="justify-end">
<InputGroupButton
type="submit" type="submit"
className="deck-primary" variant="default"
disabled={disabled || !topic.trim() || validSelection.length === 0} size="sm"
disabled={
disabled || !topic.trim() || validSelection.length === 0
}
> >
送信 送信
</button> </InputGroupButton>
</InputGroupAddon>
</InputGroup>
</fieldset> </fieldset>
</form> </form>
) )
@@ -518,7 +606,9 @@ function ResearchRunStatus({
const deck = run.deck ?? run.contextDeck const deck = run.deck ?? run.contextDeck
return ( return (
<section className="research-run" aria-label="調査状況"> <section className="research-run" aria-label="調査状況">
<p role="status">{statusNames[run.status]}</p> <Marker role="status">
<MarkerContent>{statusNames[run.status]}</MarkerContent>
</Marker>
{run.error && ( {run.error && (
<p className="deck-error" role="alert"> <p className="deck-error" role="alert">
{run.error} {run.error}
@@ -533,19 +623,27 @@ function ResearchRunStatus({
)} )}
<div className="deck-actions"> <div className="deck-actions">
{isResearchActive(run) && ( {isResearchActive(run) && (
<button type="button" disabled={busy} onClick={onCancel}> <Button
variant="outline"
size="sm"
type="button"
disabled={busy}
onClick={onCancel}
>
調査を中止 調査を中止
</button> </Button>
)} )}
{deck && ( {deck && (
<button <Button
variant="outline"
size="sm"
type="button" type="button"
onClick={() => { onClick={() => {
onOpenDeck(deck) onOpenDeck(deck)
}} }}
> >
一時デッキで開く 一時デッキで開く
</button> </Button>
)} )}
</div> </div>
</section> </section>
+19
View File
@@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
+1 -1
View File
@@ -38,7 +38,7 @@ function RootContent() {
function RootDocument({ children }: { children: React.ReactNode }) { function RootDocument({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="ja"> <html lang="ja" className="dark">
<head> <head>
<HeadContent /> <HeadContent />
</head> </head>
+18 -61
View File
@@ -1,4 +1,5 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */ /* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
@import "./ui.css";
@import "../tokens.css"; @import "../tokens.css";
:root { :root {
@@ -6,10 +7,7 @@
--surface: var(--color-paper-2); --surface: var(--color-paper-2);
--paper: var(--color-paper); --paper: var(--color-paper);
--ink: var(--color-ink); --ink: var(--color-ink);
--secondary: var(--color-muted);
--accent: var(--color-accent);
--accent-hover: var(--color-accent-hover); --accent-hover: var(--color-accent-hover);
--border: var(--color-rule);
--control-border: var(--color-control-rule); --control-border: var(--color-control-rule);
--error: var(--color-error); --error: var(--color-error);
color: var(--ink); color: var(--ink);
@@ -37,7 +35,7 @@ select {
font: inherit; font: inherit;
} }
a { a {
color: var(--accent); color: var(--primary);
text-underline-offset: 0.18em; text-underline-offset: 0.18em;
} }
a:active { a:active {
@@ -47,47 +45,6 @@ a:active {
outline: var(--rule-active) solid var(--color-focus); outline: var(--rule-active) solid var(--color-focus);
outline-offset: 2px; outline-offset: 2px;
} }
.app {
width: 100%;
height: 100dvh;
min-height: 0;
background: var(--canvas);
}
.app-deck {
display: grid;
grid-template-columns: 188px minmax(0, 1fr);
}
.app-sidebar {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-lg);
padding: var(--space-md) var(--space-sm);
border-right: var(--rule-hairline) solid var(--border);
overflow-y: auto;
}
svg {
flex-shrink: 0;
}
.sidebar-note {
margin-top: auto;
margin-bottom: 0;
color: var(--secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.reader {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
margin: var(--space-sm);
padding: var(--space-xs);
border: var(--rule-hairline) solid var(--border);
border-radius: 16px;
background: var(--paper);
overflow: hidden;
}
h1 { h1 {
margin: 0; margin: 0;
min-width: 0; min-width: 0;
@@ -99,24 +56,24 @@ h1 {
} }
.handle { .handle {
display: block; display: block;
color: var(--secondary); color: var(--muted-foreground);
font-size: var(--text-xs); font-size: var(--text-xs);
} }
@media (max-width: 767px) {
.app-deck { .workspace .reader {
grid-template-columns: minmax(0, 1fr); overflow: hidden;
grid-template-rows: auto minmax(0, 1fr);
}
.app-sidebar {
flex-direction: row;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs);
overflow: visible;
border-right: 0;
border-bottom: var(--rule-hairline) solid var(--border);
} }
.sidebar-note { .sidebar-note {
display: none; color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
/* biome-ignore lint/complexity/noImportantStyles: Respect reduced motion over registry component animation utilities. */
animation-duration: 0.01ms !important;
/* biome-ignore lint/complexity/noImportantStyles: Respect reduced motion over registry component transition utilities. */
transition-duration: 0.01ms !important;
} }
} }
+82
View File
@@ -0,0 +1,82 @@
/* Hallmark · macrostructure: sidebar-09 Workbench · tone: compact · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H4 E4 S5 R5 V4 · mobile: pass 320/375/414/768 */
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--overlay: oklch(0 0 0 / 45%);
}
@theme inline {
--font-sans: var(--font-body);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-overlay: var(--overlay);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+15 -15
View File
@@ -1,20 +1,20 @@
/* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: Twitter blue */ /* Hallmark · macrostructure: Workbench · tone: compact · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */ /* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
:root { :root {
--color-canvas: oklch(19% 0.021 250); --color-canvas: var(--background);
--color-paper: oklch(24% 0.025 250); --color-paper: var(--background);
--color-paper-2: oklch(28% 0.027 250); --color-paper-2: var(--card);
--color-paper-3: oklch(33% 0.031 250); --color-paper-3: var(--muted);
--color-ink: oklch(95% 0.005 250); --color-ink: var(--foreground);
--color-ink-2: oklch(85% 0.015 250); --color-ink-2: var(--foreground);
--color-muted: oklch(72% 0.025 250); --color-muted: var(--muted-foreground);
--color-accent: oklch(72% 0.15 240); --color-accent: var(--primary);
--color-accent-hover: oklch(79% 0.13 240); --color-accent-hover: var(--foreground);
--color-accent-ink: oklch(18% 0.03 250); --color-accent-ink: var(--primary-foreground);
--color-rule: oklch(34% 0.023 250); --color-rule: var(--border);
--color-control-rule: oklch(47% 0.029 250); --color-control-rule: var(--input);
--color-focus: oklch(79% 0.13 240); --color-focus: var(--ring);
--color-error: oklch(78% 0.13 20); --color-error: var(--destructive);
--color-success: oklch(78% 0.13 155); --color-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%); --color-shadow: oklch(10% 0.015 250 / 45%);
--color-overlay: oklch(10% 0.015 250 / 70%); --color-overlay: oklch(10% 0.015 250 / 70%);
+2 -1
View File
@@ -1,8 +1,9 @@
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react' import viteReact from '@vitejs/plugin-react'
import { nitro } from 'nitro/vite' import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
export default defineConfig({ export default defineConfig({
plugins: [nitro(), tanstackStart(), viteReact()], plugins: [tailwindcss(), nitro(), tanstackStart(), viteReact()],
}) })