3 Commits
79 changed files with 8974 additions and 1148 deletions
+24
View File
@@ -1,4 +1,28 @@
[
{
"date": "2026-09-28",
"macrostructure": "communication workbench and append-only journal",
"theme": "shadcn base-nova neutral",
"scope": "support",
"enrichment": "none",
"brief": "Compact execution support integrated with common sidebar, editable reply review and journal corrections"
},
{
"date": "2026-09-28",
"macrostructure": "sidebar-09 Inbox",
"theme": "shadcn base-nova neutral",
"scope": "inbox",
"enrichment": "none",
"brief": "Shared navigation with Twitter Lite, inferred-interest article list and reading pane; no manual interest settings"
},
{
"date": "2026-09-28",
"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",
"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"]
}
+41 -6
View File
@@ -23,7 +23,19 @@ bound to a connection account, so platforms and multiple accounts work side by s
- Saved chat history with deck, account and citation restoration across devices
and backend restarts
Both `/` and `/deck` open the deck workspace. The separate reader, search,
Both `/` and `/deck` open the deck workspace. A shared sidebar also opens
the article reader at `/inbox`. This UI-only prototype shows articles inferred
from past reading, searches, and research, with no manual interest settings.
Articles are fictional samples; search and article selection work locally.
On mobile, the article list and reader switch views. The shared sidebar’s bottom
avatar opens connected-account management from either workspace.
The shared sidebar also contains `/support` (communication support) and `/journal`
(notes and health records). These use fictional conversations and local session
state to preview reply review, scheduling, deferral, recording, and corrections.
See [execution support](docs/execution-support.md) for scope and limitations.
The former SNS reader, search,
list, user-profile, and conversation routes have been removed. Original-post
links on post cards open their source site; SNS reply threads are not rendered
inside the app.
@@ -66,6 +78,9 @@ profiles; Mastodon credentials never go to the browser.
nix develop -c pnpm dev
nix develop -c pnpm dev:tailscale
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 test
nix develop -c pnpm test:e2e
@@ -163,11 +178,31 @@ and `TWITTER_LITE_LIVE_QUERY` overrides the search text.
## Deck workspace
The interface uses a full-height, dark TweetDeck-style workbench: a deck sidebar,
compact column headers, and independently scrolling post columns. On narrow
screens, the sidebar becomes a compact top bar. Add/edit forms open in native
dialogs; Escape closes them and returns focus. Column menus contain editing,
ordering, and deletion; refresh stays available directly in each header.
The interface uses shadcn/ui's Base UI components and Tailwind CSS v4, with a
collapsible sidebar based on
[sidebar-09](https://github.com/shadcn-ui/ui/tree/98a1fe67b439324ddc857f47fbdce056600a4329/apps/v4/registry/bases/base/blocks/sidebar-09).
The workspace retains compact column headers and independently scrolling post
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.
Choose a connected account and one of its supported sources. Account
+19 -3
View File
@@ -8,9 +8,25 @@
"!!**/test-results"
]
},
"formatter": { "enabled": true, "indentStyle": "space" },
"linter": { "enabled": true, "rules": { "recommended": true } },
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"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"
}
}
+34
View File
@@ -0,0 +1,34 @@
# Execution support prototype
The shared sidebar opens `/support` (連絡) and `/journal` (記録), alongside
リーダー and Twitter Lite. These are UI-only prototypes with fictional contacts
and in-memory state. Reloading or leaving the route resets the prototype state.
No messages, calendar events, medical records, or Rensheng pages are written.
## Reference and scope
The functional reference is the user's life brief
`briefs/2026-09-25-executive-support-design.md`, together with the public
[Beeper Copilot concept](https://beeper-copilot-lp.vercel.app/).
The prototype adopts the execution flows, not private facts from the life repo
or the reference site's deployment and privacy claims.
- Communication: unified conversations, a maximum of three initial actions,
deferral, waiting, dismissal, and a full list; source messages, summaries,
editable reply styles, explicit review, and simulated sending.
- Scheduling: choosing a candidate and reviewing a simulated calendar entry.
- Context: person notes with links back to the sample conversation and a local
record of the simulated outcome. No permanent trait or goal is inferred.
- Journal: free notes, resumption points, user-entered medication and weight
records; occurrence and recording times, append-only corrections, and a
selectable summary preview for a consultation.
The intended memory boundary remains Rensheng: execution events can eventually
be compiled into durable context after source comparison and user correction.
This prototype neither implements that compiler nor creates a second personal
context database. Missing records remain unknown; there are no medication,
dose, weight-target, or treatment recommendations.
The UI uses existing shadcn Base UI components and neutral theme tokens.
Shared account management continues to use the existing connection system;
the new communication and journal prototypes do not consume those accounts.
+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.
+11 -6
View File
@@ -2,12 +2,17 @@
## Workspace
Both `/` and `/deck` render the deck-only application. A dark sidebar switches
decks, adds columns, and jumps to a column. Up to six ordered columns scroll
independently; only the active deck mounts and fetches its columns. On mobile,
the sidebar becomes a compact top bar. Native dialogs handle creation and
editing, with Escape and focus restoration. Column menus offer editing,
ordering, and deletion; refresh and pagination are manual.
Both `/` and `/deck` render the research workspace. A collapsible shadcn/ui
sidebar based on `sidebar-09` shares its navigation rail with the article reader
at `/inbox`. Within Twitter Lite, it switches between research chat and deck management.
Deck management switches decks and adds columns. The avatar at the bottom of the
shared sidebar opens connected-account management in a dialog from either workspace.
Up to six ordered columns scroll independently; only the active deck
mounts and fetches its columns. On mobile, the sidebar opens as a sheet.
shadcn Base UI dialogs handle creation and editing, with Escape and focus
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
column. Original-post links open their source site. Separate reader, search,
+34
View File
@@ -4,7 +4,13 @@ test('opens account management after hydration without inheriting real OAuth con
page,
}) => {
await page.goto('/')
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
}
await expect(manage).toBeEnabled()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
@@ -20,6 +26,27 @@ test('opens account management after hydration without inheriting real OAuth con
await expect(manage).toBeFocused()
})
test('opens account management from the inbox footer and restores focus on close', async ({
page,
a11y,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
await expect(manage).toBeVisible()
await manage.click()
const dialog = page.getByRole('dialog', { name: '接続アカウント' })
await expect(dialog).toBeVisible()
await expect(
dialog.getByText('利用するMastodonサーバーがまだ設定されていません。'),
).toBeVisible()
expect((await a11y().analyze()).violations).toEqual([])
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(manage).toBeFocused()
await expect(page.getByRole('textbox', { name: '記事を検索' })).toBeVisible()
})
test('rejects an unbound OAuth callback without contacting an instance', async ({
page,
}) => {
@@ -28,6 +55,13 @@ test('rejects an unbound OAuth callback without contacting an instance', async (
await expect(page.getByRole('alert')).toContainText(
'Mastodonの認可を完了できませんでした。',
)
await expect(
page.getByRole('button', { name: '名前を変更', includeHidden: true }),
).toBeEnabled()
const manage = page.getByRole('button', { name: '接続アカウントを管理' })
if (!(await manage.isVisible())) {
await page.getByRole('button', { name: 'サイドバーを切り替え' }).click()
}
await expect(
page.getByRole('button', { name: '接続アカウントを管理' }),
).toBeEnabled()
+57 -9
View File
@@ -1,5 +1,6 @@
import type { Page } from '@playwright/test'
import { expect, test } from '../fixtures'
import { closeMobileSidebar, showDeckManagement } from '../workspace'
async function addColumn(
page: Page,
@@ -8,6 +9,7 @@ async function addColumn(
profile = 'e2e',
kind = 'search',
) {
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const editor = page.getByRole('form', { name: 'カラムを追加', exact: true })
await editor.getByLabel('カラム名', { exact: true }).fill(title)
@@ -28,6 +30,7 @@ async function addColumn(
)
.fill(query)
await editor.getByRole('button', { name: '追加して検索' }).click()
await closeMobileSidebar(page)
await expect(
page
.getByRole('region', { name: title, exact: true })
@@ -44,7 +47,9 @@ test.beforeEach(async ({ page }) => {
await page
.getByRole('button', { name: 'デッキとして保存', exact: true })
.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 ({
@@ -67,8 +72,8 @@ test('binds identical searches to independent profiles through paging, editing a
await expect(
second.getByText('alternate · Latest · all page 2'),
).toBeVisible()
await second.locator('summary[aria-label="別の接続の操作"]').click()
await second.getByRole('button', { name: '編集', exact: true }).click()
await second.getByRole('button', { name: '別の接続の操作' }).click()
await page.getByRole('menuitem', { name: '編集', exact: true }).click()
await page
.getByRole('combobox', { name: '接続プロファイル', exact: true })
.selectOption({ label: 'e2e' })
@@ -93,25 +98,32 @@ test('creates, switches, renames and deletes persisted deck profiles', async ({
await page.getByLabel('調査テーマ・デッキ名').fill('最初の調査')
await page.getByRole('button', { name: '名前を保存' }).click()
await addColumn(page, '最初の観点', 'first-deck')
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('別の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await expect(page.locator('.deck-column')).toHaveCount(0)
await addColumn(page, '別の観点', 'second-deck', 'alternate')
await showDeckManagement(page)
await page
.getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '最初の調査' })
await closeMobileSidebar(page)
await expect(page.locator('.deck-column h2')).toHaveText(['最初の観点'])
await page.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await page
.getByLabel('デッキプロファイル', { exact: true })
.selectOption({ label: '別の調査' })
await closeMobileSidebar(page)
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.reload()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('最初の調査')
await showDeckManagement(page)
await expect(
page.getByLabel('デッキプロファイル', { exact: true }).locator('option'),
).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 list.getByRole('button', { name: '続きを読み込む' }).click()
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 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 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'),
fullPage: true,
})
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
expect((await a11y().analyze()).violations).toEqual([])
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 ({
page,
}) => {
await showDeckManagement(page)
const opener = page.getByRole('button', { name: 'カラムを追加', exact: true })
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.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(page.locator('.deck-column')).toHaveCount(0)
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 ({
page,
}) => {
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
await page.getByLabel('カラム名', { exact: true }).fill('購読リスト')
await page
@@ -263,6 +282,7 @@ test('selects a list from the column profile catalog and refreshes it on profile
'1234567890',
)
await page.getByRole('button', { name: '追加して検索' }).click()
await closeMobileSidebar(page)
await expect(
page
.getByRole('region', { name: '購読リスト', exact: true })
@@ -347,11 +367,15 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
legacy,
)
await page.reload()
await showDeckManagement(page)
const selector = page.getByLabel('デッキプロファイル', { exact: true })
await expect(selector.locator('option')).toHaveCount(1)
await closeMobileSidebar(page)
await page.getByRole('button', { name: '旧デッキを取り込む' }).click()
await showDeckManagement(page)
await expect(selector.locator('option')).toHaveCount(2)
await selector.selectOption({ label: '以前の調査' })
await closeMobileSidebar(page)
await expect(
page.getByText('Latest · all page 1', { exact: true }),
).toBeVisible()
@@ -368,5 +392,29 @@ test('imports old browser decks explicitly and avoids duplicate imports after re
await expect(
page.getByRole('button', { name: '旧デッキを取り込む' }),
).toHaveCount(0)
await showDeckManagement(page)
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(
'画面を回転しても残る調査',
)
})
+101
View File
@@ -0,0 +1,101 @@
import { expect, test } from '../fixtures'
test('selects articles and keeps the reader focused on their content', async ({
page,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await expect(rows).toHaveCount(9)
const articleTitle = 'エージェントに渡す文脈は、短くてもよい'
await rows.filter({ hasText: articleTitle }).click()
const reader = page.getByRole('article', { name: articleTitle, exact: true })
await expect(reader).toBeVisible()
await expect(
reader.getByRole('heading', { name: articleTitle, exact: true }),
).toBeVisible()
await expect(
reader.getByText(
'まず目的を一文で書き、次に変えてはいけない条件を添える。',
{ exact: false },
),
).toBeVisible()
await expect(page.getByText('FOR YOU', { exact: true })).toHaveCount(0)
await expect(page.getByText('モック', { exact: true })).toHaveCount(0)
await expect(
page.getByText('この記事が届いた理由', { exact: true }),
).toHaveCount(0)
await expect(
page.getByRole('button', { name: /あなたへ|あとで読む|未読のみ/ }),
).toHaveCount(0)
const back = page.getByRole('button', { name: '記事一覧に戻る' })
if (await back.isVisible()) await back.click()
await rows
.filter({ hasText: '3カラムの余白が、情報を読むリズムをつくる' })
.click()
await expect(
page.getByRole('article', {
name: '3カラムの余白が、情報を読むリズムをつくる',
exact: true,
}),
).toBeVisible()
})
test('recovers from empty search and shares navigation with Twitter Lite', async ({
page,
a11y,
}) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
const rows = page.locator('.inbox-article-row')
await page
.getByRole('textbox', { name: '記事を検索' })
.fill('存在しない検索語xyz')
await expect(rows).toHaveCount(0)
await expect(
page.getByText('記事がありません', { exact: true }),
).toBeVisible()
await page.getByRole('button', { name: '検索をクリア' }).click()
await expect(rows).toHaveCount(9)
expect((await a11y().analyze()).violations).toEqual([])
await expect(
page.getByRole('button', { name: '興味を編集', exact: true }),
).toHaveCount(0)
await page.getByRole('link', { name: 'Twitter Lite', exact: true }).click()
await expect(page).toHaveURL(/\/deck$/)
await page.getByRole('link', { name: 'リーダー', exact: true }).click()
await expect(page).toHaveURL(/\/inbox$/)
await expect(rows).toHaveCount(9)
})
test('keeps article navigation readable without horizontal overflow', async ({
page,
a11y,
}, testInfo) => {
await page.goto('/inbox')
await page.waitForLoadState('networkidle')
for (const width of [320, 375, 414, 768]) {
await page.setViewportSize({ width, height: 850 })
await expect(
page.getByRole('textbox', { name: '記事を検索' }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.inbox-article-row').first().click()
const reader = page.getByRole('article', {
name: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
exact: true,
})
await expect(reader).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.screenshot({
path: testInfo.outputPath(`inbox-reader-${width}.png`),
})
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: '記事一覧に戻る' })
if (await back.isVisible()) await back.click()
}
})
+85
View File
@@ -1,6 +1,11 @@
import type { DeckColumn } from '../../src/features/decks/model'
import type { ResearchRun } from '../../src/features/research/model'
import { expect, test } from '../fixtures'
import {
closeMobileSidebar,
showDeckManagement,
showResearch,
} from '../workspace'
test('pushes the current research snapshot on each SSE connection', async ({
page,
@@ -37,6 +42,7 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
page,
}) => {
await page.goto('/')
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', {
name: '接続プロファイル',
@@ -45,9 +51,11 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape')
await closeMobileSidebar(page)
const run: ResearchRun = {
id: '36ad8cc7-318c-46ae-94fa-28d30336f027',
threadId: 'conversation-test',
connectionIds: [connectionId],
topic: '既存の観点を使って調査して',
status: 'running',
startedAt: Date.now(),
@@ -89,10 +97,14 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
}),
)
await page.reload()
await showResearch(page)
const conversation = page.getByRole('log', { name: '調査の会話' })
await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('list_decks: 完了')
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(['最初の観点'])
run.deckVersion = 2
run.status = 'complete'
@@ -114,19 +126,79 @@ test('restores the conversation and growing temporary deck from reconnect snapsh
},
})
await page.reload()
await showResearch(page)
await expect(conversation).toContainText('既存の観点を使って調査して')
await expect(conversation).toContainText('比較する観点を追加しました。')
await expect(page.locator('.deck-column h2')).toHaveText([
'最初の観点',
'比較の観点',
])
await closeMobileSidebar(page)
await expect(
page.getByRole('button', { name: 'デッキとして保存', exact: true }),
).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 expect(
page.getByRole('button', { name: '送信', exact: true }),
).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 ({
@@ -144,6 +216,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
}) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/')
await showDeckManagement(page)
await page.getByRole('button', { name: 'カラムを追加', exact: true }).click()
const accounts = page.getByRole('combobox', {
name: '接続プロファイル',
@@ -152,6 +225,7 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
await accounts.selectOption({ label: 'e2e' })
const connectionId = await accounts.inputValue()
await page.keyboard.press('Escape')
await closeMobileSidebar(page)
const source: DeckColumn = {
id: 'citation-column-2',
title: '引用の観点',
@@ -213,8 +287,14 @@ test('renders Markdown and reveals an unloaded citation in an offscreen deck col
}),
)
await page.reload()
await showResearch(page)
const chat = page.getByRole('log', { name: '調査の会話' })
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(
chat.locator('strong').filter({ hasText: '重要な発見' }),
).toBeVisible()
@@ -246,6 +326,7 @@ test('explains unconfigured Codex and prevents starting research without affecti
const heading = page.getByRole('heading', { level: 1 })
await expect(heading).toBeVisible()
const originalTitle = await heading.textContent()
await showResearch(page)
const panel = page.getByRole('complementary', { name: '調査チャット' })
await expect(panel).toBeVisible()
await expect(
@@ -257,10 +338,14 @@ test('explains unconfigured Codex and prevents starting research without affecti
await expect(panel.getByLabel('メッセージ', { exact: true })).toBeDisabled()
const accessibility = await a11y().analyze()
expect(accessibility.violations).toEqual([])
await closeMobileSidebar(page)
await expect(heading).toHaveText(originalTitle ?? '')
await showDeckManagement(page)
await page.getByRole('button', { name: 'デッキを作成', exact: true }).click()
await page.getByLabel('新しいデッキ名').fill('手動の調査')
await page.getByRole('button', { name: '作成', exact: true }).click()
await closeMobileSidebar(page)
await expect(heading).toHaveText('手動の調査')
await showResearch(page)
await expect(panel).toBeVisible()
})
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from '../fixtures'
const reviewSubject = '来週のレビュー、日程を決める'
const coffeeSubject = '久しぶりに、お茶でも'
test('reviews an edited reply and keeps the simulated result in history', async ({
page,
a11y,
}) => {
await page.goto('/support')
await page.waitForLoadState('networkidle')
const rows = page.locator('.support-row')
await expect(rows).toHaveCount(3)
await rows.filter({ hasText: reviewSubject }).click()
await page.getByRole('button', { name: '丁寧に', exact: true }).click()
const reply = page.getByRole('textbox', { name: '返信', exact: true })
await expect(reply).toHaveValue(/資料をご共有/)
const edited = '資料を確認しました。初回の導線について当日お話しします。'
await reply.fill(edited)
const slot = '10月5日(月)14:00–14:30'
await page.getByRole('combobox', { name: '日程候補' }).selectOption(slot)
await page.getByRole('button', { name: '返信に入れる' }).click()
await expect(reply).toHaveValue(`${edited}\n\n${slot}はいかがでしょうか。`)
await page.getByRole('button', { name: '送信内容を確認' }).click()
const dialog = page.getByRole('dialog', { name: '送信内容の確認' })
await expect(dialog).toContainText(edited)
await expect(dialog).toContainText(slot)
await expect(dialog).toContainText('外部には送信されません')
expect((await a11y().analyze()).violations).toEqual([])
await dialog.getByRole('button', { name: '戻る', exact: true }).click()
await expect(reply).toHaveValue(new RegExp(edited))
await page.getByRole('button', { name: '送信内容を確認' }).click()
await dialog.getByRole('button', { name: '送信を試す' }).click()
await expect(dialog).toHaveCount(0)
await expect(rows.filter({ hasText: reviewSubject })).toHaveCount(0)
await page.getByRole('button', { name: '履歴', exact: true }).click()
await rows.filter({ hasText: reviewSubject }).click()
await expect(page.locator('.support-outcome')).toContainText(edited)
await expect(page.locator('.support-outcome')).toContainText(slot)
})
test('defers a conversation and restores it to the current queue', async ({
page,
}) => {
await page.goto('/support')
await page.waitForLoadState('networkidle')
const rows = page.locator('.support-row')
await rows.filter({ hasText: coffeeSubject }).click()
await page
.getByRole('textbox', { name: '返信', exact: true })
.fill('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今は置いておく' }).click()
await expect(rows.filter({ hasText: coffeeSubject })).toHaveCount(0)
await page.getByRole('button', { name: 'あとで', exact: true }).click()
await rows.filter({ hasText: coffeeSubject }).click()
await expect(
page.getByRole('textbox', { name: '返信', exact: true }),
).toHaveValue('土曜の予定を確認してから返事するね。')
await page.getByRole('button', { name: '今に戻す' }).click()
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
if (await back.isVisible()) await back.click()
await expect(rows.filter({ hasText: coffeeSubject })).toBeVisible()
await expect(
page.getByRole('button', { name: '今', exact: true }),
).toHaveAttribute('aria-pressed', 'true')
})
test('keeps corrections as new records and summarizes only selected current facts', async ({
page,
a11y,
}) => {
await page.goto('/journal')
await page.waitForLoadState('networkidle')
await expect(
page.getByRole('button', { name: '記録する', exact: true }),
).toBeDisabled()
await page
.getByRole('textbox', { name: '記録内容' })
.fill('次はレビュー資料の導線を確認する')
await page.getByRole('button', { name: '記録する', exact: true }).click()
await expect(page.getByRole('article', { name: 'メモの記録' })).toContainText(
'発生日時: 不明',
)
await page.getByRole('button', { name: '体重', exact: true }).click()
await page.getByRole('spinbutton', { name: '体重', exact: true }).fill('65.2')
await page.getByRole('textbox', { name: '記録元' }).fill('自宅の体重計')
await page.getByRole('button', { name: '記録する', exact: true }).click()
await page
.getByRole('article', { name: '体重の記録' })
.getByRole('button', { name: '訂正する' })
.click()
const correction = page.getByRole('dialog', { name: '記録を訂正' })
await correction
.getByRole('spinbutton', { name: '体重', exact: true })
.fill('65.8')
await correction.getByRole('button', { name: '訂正を追加' }).click()
const weights = page.getByRole('article', { name: '体重の記録' })
await expect(weights).toHaveCount(2)
await expect(weights.filter({ hasText: '65.2 kg' })).toContainText('訂正前')
await expect(weights.filter({ hasText: '65.8 kg' })).toContainText('訂正')
await page
.getByRole('button', { name: '記録をまとめる', exact: true })
.click()
const review = page.getByRole('dialog', { name: '記録をまとめる' })
const summary = review.getByRole('textbox', { name: '記録のまとめ' })
await expect(summary).toHaveValue(/65.8 kg/)
await expect(summary).not.toHaveValue(/65.2 kg/)
await expect(summary).toHaveValue(/記録元: 自宅の体重計/)
await expect(summary).toHaveValue(/発生日時が不明の記録: 2件/)
await review.getByRole('checkbox', { name: /次はレビュー資料/ }).uncheck()
await expect(summary).not.toHaveValue(/次はレビュー資料/)
await expect(summary).toHaveValue(/記録のない期間の服薬・体重は不明/)
expect((await a11y().analyze()).violations).toEqual([])
})
test('fits communication and journal interactions in narrow screens', async ({
page,
a11y,
}) => {
for (const width of [320, 375, 768]) {
await page.setViewportSize({ width, height: 850 })
await page.goto('/support')
await page.waitForLoadState('networkidle')
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
await page.locator('.support-row').first().click()
await expect(
page.getByRole('textbox', { name: '返信', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
const back = page.getByRole('button', { name: '連絡一覧に戻る' })
if (await back.isVisible()) await back.click()
await page.getByRole('link', { name: '記録', exact: true }).click()
await page.getByRole('button', { name: '体重', exact: true }).click()
await expect(
page.getByRole('spinbutton', { name: '体重', exact: true }),
).toBeVisible()
expect(
await page.evaluate(() => document.documentElement.scrollWidth),
).toBe(width)
if (width === 320) expect((await a11y().analyze()).violations).toEqual([])
}
})
+2 -5
View File
@@ -117,12 +117,9 @@ test('creates temporary research, edits it, persists explicitly and reopens it o
).toBeVisible()
await page
.getByRole('region', { name: '日本語', exact: true })
.locator('summary')
.click()
await page
.getByRole('region', { name: '日本語', exact: true })
.getByRole('button', { name: '編集', exact: true })
.getByRole('button', { name: '日本語の操作' })
.click()
await page.getByRole('menuitem', { name: '編集', exact: true }).click()
const replaced = await executeTool(page, 'set_deck', {
deckId: deck.id,
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;
pnpm = pkgs.pnpm_11;
fetcherVersion = 4;
hash = "sha256-dV9yHImzYzkwE5ZK75zWLkpJ5+V0Rvqg8/zC0zRw76I=";
hash = "sha256-+DqNt+58TrI0W8SU//JNdYYJtCdKNIG7gfWRxno36QQ=";
};
nativeBuildInputs = with pkgs; [
+20 -3
View File
@@ -12,7 +12,12 @@
],
"ignore": [
"e2e/generated/**"
]
],
"ignoreIssues": {
"src/components/ui/**": [
"exports"
]
}
},
"imports": {
"#/*": "./src/*"
@@ -29,28 +34,36 @@
"build": "vite build && node scripts/build-tools.mjs",
"start": "HOST=127.0.0.1 node .output/server/index.mjs",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint": "biome check . && pnpm lint:ui",
"knip": "knip",
"format": "biome check --write .",
"test": "vitest run",
"test:watch": "vitest",
"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": {
"@base-ui/react": "1.8.0",
"@shadcn/react": "^0.3.1",
"@tanstack/react-query": "5.101.2",
"@tanstack/react-router": "1.170.17",
"@tanstack/react-router-ssr-query": "1.167.1",
"@tanstack/react-start": "1.168.27",
"@yuta/bird": "0.10.1",
"better-sqlite3": "13.0.3",
"class-variance-authority": "0.7.1",
"cn": "^0.4.0",
"drizzle-orm": "0.45.3",
"lucide-react": "1.48.0",
"nitro": "3.0.260610-beta",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-markdown": "10.1.0",
"remark-gfm": "4.0.1",
"sanitize-html": "2.17.7",
"shadcn": "4.21.0",
"tw-animate-css": "1.4.0",
"usewebmcp": "5.1.0",
"zod": "4.4.3"
},
@@ -58,6 +71,8 @@
"@axe-core/playwright": "4.11.1",
"@biomejs/biome": "2.5.3",
"@playwright/test": "1.61.1",
"@shadcn/lint": "^0.2.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.2",
@@ -71,6 +86,8 @@
"jsdom": "29.1.1",
"knip": "6.27.0",
"orval": "8.24.0",
"oxlint": "^1.85.0",
"tailwindcss": "^4.3.3",
"tsx": "4.23.12",
"typescript": "7.0.2",
"vite": "8.1.4",
+2655 -4
View File
File diff suppressed because it is too large Load Diff
+129 -14
View File
@@ -1,21 +1,136 @@
export function AppShell({
import { Columns3, MessageSquare, PanelLeftClose } from 'lucide-react'
import { type CSSProperties, type ReactNode, useState } from 'react'
import {
Sidebar,
SidebarInset,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
type ShellProps = {
hasTemporaryDecks: boolean
sidebar: ReactNode
researchChat: (closeMobileSidebar: () => void) => ReactNode
children: ReactNode
}
export function AppShell(props: ShellProps) {
return (
<TooltipProvider delay={800}>
<SidebarProvider
className="workspace h-dvh min-h-0 overflow-hidden"
style={
{
'--sidebar-width': '25rem',
'--sidebar-width-icon': '4rem',
} as CSSProperties
}
>
<Workspace {...props} />
</SidebarProvider>
</TooltipProvider>
)
}
function Workspace({
sidebar,
researchChat,
children,
}: {
sidebar: React.ReactNode
researchChat: React.ReactNode
children: React.ReactNode
}) {
hasTemporaryDecks,
}: 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 (
<main className="app app-deck">
{researchChat}
<section className="reader">
<section className="deck-toolbar" aria-label="デッキ管理">
{sidebar}
<>
<Sidebar
collapsible="icon"
className="overflow-hidden *:data-[sidebar=sidebar]:flex-row"
>
<WorkspaceNavigation
hasTemporaryDecks={hasTemporaryDecks}
footer={
<SidebarMenuItem>
<SidebarMenuButton
aria-label="サイドバーを閉じる"
tooltip={{ children: 'サイドバーを閉じる', hidden: false }}
onClick={() =>
isMobile ? setOpenMobile(false) : setOpen(false)
}
className="size-10 justify-center p-0"
>
<PanelLeftClose />
</SidebarMenuButton>
</SidebarMenuItem>
}
>
{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="size-10 justify-center p-0"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</WorkspaceNavigation>
<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>
</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}
</section>
{children}
</section>
</main>
</SidebarInset>
</>
)
}
+36 -33
View File
@@ -1,51 +1,54 @@
import { useEffect, useId, useRef } from 'react'
import { cn } from 'cn'
import { Icon } from './icon'
import { Button } from './ui/button'
import {
DialogClose,
DialogContent,
DialogHeader,
Dialog as DialogRoot,
DialogTitle,
} from './ui/dialog'
export function Dialog({
title,
onClose,
children,
className,
}: {
title: string
className?: string
onClose: () => void
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 (
<dialog
ref={dialog}
className="deck-dialog"
aria-labelledby={headingId}
onCancel={(event) => {
event.preventDefault()
onClose()
<DialogRoot
open
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<div className="dialog-heading">
<h2 id={headingId}>{title}</h2>
<button
type="button"
className="icon-button"
aria-label="閉じる"
onClick={onClose}
<DialogContent
showCloseButton={false}
className={cn('max-h-[calc(100dvh-2rem)] overflow-y-auto', className)}
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DialogClose
render={
<Button
variant="ghost"
size="icon-sm"
className="absolute right-2 top-2"
aria-label="閉じる"
/>
}
>
<Icon name="close" />
</button>
</div>
{children}
</dialog>
</DialogClose>
{children}
</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 }
+137
View File
@@ -0,0 +1,137 @@
import { Link, useLocation } from '@tanstack/react-router'
import {
Inbox,
Layers3,
MessagesSquare,
NotebookPen,
Search,
} from 'lucide-react'
import { type ReactNode, useState } from 'react'
import { Avatar, AvatarFallback } from '#/components/ui/avatar'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
} from '#/components/ui/sidebar'
import { ConnectionManagerDialog } from '#/features/connections/connection-manager-dialog'
type WorkspaceNavigationProps = {
children?: ReactNode
footer?: ReactNode
hasTemporaryDecks?: boolean
}
export function WorkspaceNavigation({
children,
footer,
hasTemporaryDecks,
}: WorkspaceNavigationProps) {
const [managingAccounts, setManagingAccounts] = useState(false)
const pathname = useLocation({ select: (location) => location.pathname })
const navigation = [
{
title: '連絡',
to: '/support',
icon: MessagesSquare,
active: pathname === '/support',
},
{
title: '記録',
to: '/journal',
icon: NotebookPen,
active: pathname === '/journal',
},
{
title: 'リーダー',
to: '/inbox',
icon: Inbox,
active: pathname === '/inbox',
},
{
title: 'Twitter Lite',
to: '/deck',
icon: Search,
active: pathname === '/' || pathname === '/deck',
},
] as const
return (
<Sidebar
collapsible="none"
className="workspace-navigation w-16! shrink-0 border-r"
>
<SidebarHeader className="items-center py-4">
<div
className="flex size-9 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
role="img"
aria-label="ワークスペース"
>
<Layers3 className="size-5" aria-hidden="true" />
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup className="px-3">
<SidebarGroupContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
aria-label={item.title}
aria-current={item.active ? 'page' : undefined}
tooltip={{ children: item.title, hidden: false }}
isActive={item.active}
render={<Link to={item.to} />}
className="size-10 justify-center p-0"
>
<item.icon />
<span className="sr-only">{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{children && (
<>
<SidebarSeparator className="mx-3 w-auto" />
<SidebarGroup className="px-3">
<SidebarGroupContent>
<SidebarMenu>{children}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</>
)}
</SidebarContent>
<SidebarFooter className="items-center px-3 pb-4">
{footer && <SidebarMenu>{footer}</SidebarMenu>}
<SidebarMenu className="mt-2">
<SidebarMenuItem>
<SidebarMenuButton
aria-label="接続アカウントを管理"
tooltip={{ children: '接続アカウントを管理', hidden: false }}
className="size-10 justify-center p-0"
onClick={() => setManagingAccounts(true)}
>
<Avatar className="size-8">
<AvatarFallback>Y</AvatarFallback>
</Avatar>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{managingAccounts && (
<ConnectionManagerDialog
onClose={() => setManagingAccounts(false)}
hasTemporaryDecks={hasTemporaryDecks}
/>
)}
</Sidebar>
)
}
@@ -0,0 +1,47 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { ConnectionManager } from './connection-manager'
import { loadConnections } from './server-functions'
export function ConnectionManagerDialog({
onClose,
hasTemporaryDecks,
}: {
onClose: () => void
hasTemporaryDecks?: boolean
}) {
const fetchConnections = useServerFn(loadConnections)
const connections = useQuery({
queryKey: ['connections'],
queryFn: () => fetchConnections(),
retry: false,
})
return (
<Dialog title="接続アカウント" onClose={onClose} className="sm:max-w-2xl">
{connections.isPending ? (
<p role="status">接続アカウントを読み込んでいます…</p>
) : connections.isError ? (
<div className="grid gap-3">
<p role="alert">接続アカウントを取得できませんでした。</p>
<Button variant="outline" onClick={() => void connections.refetch()}>
再試行
</Button>
</div>
) : (
<>
{connections.data.relayError && (
<p role="alert">{connections.data.relayError}</p>
)}
<ConnectionManager
connections={connections.data.connections}
onChanged={() => connections.refetch()}
hasTemporaryDecks={hasTemporaryDecks}
/>
</>
)}
</Dialog>
)
}
@@ -5,7 +5,7 @@
.connection-manager-description,
.connection-manager-note {
margin: 0;
color: #aab8c2;
color: var(--muted-foreground);
font-size: 0.875rem;
}
.connection-manager-list {
@@ -21,7 +21,7 @@
justify-content: space-between;
gap: 0.75rem;
padding-block: 0.75rem;
border-bottom: 1px solid #38444d;
border-bottom: 1px solid var(--border);
}
.connection-manager-account {
display: grid;
@@ -29,15 +29,9 @@
min-width: 0;
overflow-wrap: anywhere;
}
.connection-manager-account > span {
.connection-manager-account > span:not([data-slot="badge"]) {
font-size: 0.8rem;
color: #aab8c2;
}
.connection-manager-account > .connection-manager-status-connected {
color: #71d6ad;
}
.connection-manager-account > .connection-manager-status-expired {
color: #f2c66d;
color: var(--muted-foreground);
}
.connection-manager-actions {
display: flex;
@@ -53,18 +47,6 @@
margin: 0;
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) {
.connection-manager-list > li {
align-items: flex-start;
+29 -16
View File
@@ -1,6 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { useServerFn } from '@tanstack/react-start'
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 {
disconnectMastodonAccount,
loadMastodonInstances,
@@ -92,15 +96,18 @@ export function ConnectionManager({
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'} ·{' '}
{new URL(connection.origin).hostname}
</span>
<span
className={`connection-manager-status connection-manager-status-${connection.status}`}
<Badge
variant={
connection.status === 'connected' ? 'secondary' : 'outline'
}
>
{statusNames[connection.status]}
</span>
</Badge>
</div>
{connection.platform === 'mastodon' && (
<div className="connection-manager-actions">
<button
<Button
variant="outline"
type="button"
disabled={busy}
aria-label={`${connection.displayName}を再接続`}
@@ -109,16 +116,17 @@ export function ConnectionManager({
}
>
再接続
</button>
</Button>
{connection.status !== 'disconnected' && (
<button
<Button
variant="destructive"
type="button"
disabled={busy}
aria-label={`${connection.displayName}の接続を解除`}
onClick={() => void remove(connection.id)}
>
接続解除
</button>
</Button>
)}
</div>
)}
@@ -139,30 +147,35 @@ export function ConnectionManager({
) : instances.isError ? (
<p role="alert">
接続先を取得できませんでした。
<button type="button" onClick={() => void instances.refetch()}>
<Button
variant="outline"
type="button"
onClick={() => void instances.refetch()}
>
再試行
</button>
</Button>
</p>
) : instances.data.origins.length === 0 ? (
<p>利用するMastodonサーバーがまだ設定されていません。</p>
) : (
<>
<label htmlFor={selectId}>Mastodonサーバー</label>
<select
<Label htmlFor={selectId}>Mastodonサーバー</Label>
<NativeSelect
className="w-full"
id={selectId}
value={origin}
onChange={(event) => setSelected(event.target.value)}
disabled={busy}
>
{instances.data.origins.map((value) => (
<option key={value} value={value}>
<NativeSelectOption key={value} value={value}>
{new URL(value).hostname}
</option>
</NativeSelectOption>
))}
</select>
<button type="submit" disabled={busy || !origin}>
</NativeSelect>
<Button type="submit" disabled={busy || !origin}>
Mastodonで認可する
</button>
</Button>
</>
)}
{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('表示順'), {
target: { value: 'Top' },
})
fireEvent.click(screen.getByLabelText('フォロー中のみ'))
fireEvent.click(screen.getByRole('checkbox', { name: 'フォロー中のみ' }))
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledOnce())
expect(screen.getByLabelText('カラム名')).toHaveValue('調査')
@@ -170,7 +170,7 @@ it('preserves title and Twitter search options after a rejected save', async ()
'from:owner WebMCP',
)
expect(screen.getByLabelText('表示順')).toHaveValue('Top')
expect(screen.getByLabelText('フォロー中のみ')).toBeChecked()
expect(screen.getByRole('checkbox', { name: 'フォロー中のみ' })).toBeChecked()
fireEvent.submit(screen.getByRole('form', { name: 'カラムを追加' }))
await waitFor(() => expect(save).toHaveBeenCalledTimes(2))
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 { 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 {
ColumnSourceEditor,
@@ -60,9 +64,9 @@ export function ColumnEditor({
}}
>
<div className="deck-editor-fields">
<label htmlFor={`${formId}-title`}>
<Label htmlFor={`${formId}-title`}>
カラム名
<input
<Input
id={`${formId}-title`}
name="title"
defaultValue={column?.title ?? ''}
@@ -70,10 +74,11 @@ export function ColumnEditor({
placeholder="例:発表元の投稿"
required
/>
</label>
<label htmlFor={`${formId}-profile`}>
</Label>
<Label htmlFor={`${formId}-profile`}>
接続プロファイル
<select
<NativeSelect
className="w-full"
id={`${formId}-profile`}
value={connectionId}
required
@@ -86,22 +91,22 @@ export function ColumnEditor({
setSource(rebindColumnSource(source, connection.platform))
}}
>
<option value="" disabled>
<NativeSelectOption value="" disabled>
選択してください
</option>
</NativeSelectOption>
{connectionId && !validConnection && (
<option value={connectionId} disabled>
<NativeSelectOption value={connectionId} disabled>
{connectionId}(利用不可)
</option>
</NativeSelectOption>
)}
{available.map((connection) => (
<option key={connection.id} value={connection.id}>
<NativeSelectOption key={connection.id} value={connection.id}>
{connection.displayName}
{connection.platform === 'mastodon' ? ' · Mastodon' : ''}
</option>
</NativeSelectOption>
))}
</select>
</label>
</NativeSelect>
</Label>
</div>
<ColumnSourceEditor
source={source}
@@ -117,16 +122,12 @@ export function ColumnEditor({
</p>
)}
<div className="deck-actions">
<button
type="submit"
className="deck-primary"
disabled={!validConnection}
>
<Button type="submit" disabled={!validConnection}>
{column ? '変更を保存' : '追加して検索'}
</button>
<button type="button" onClick={onCancel}>
</Button>
<Button variant="outline" type="button" onClick={onCancel}>
キャンセル
</button>
</Button>
</div>
</form>
)
+46 -43
View File
@@ -1,4 +1,13 @@
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 {
flattenResearchPages,
useResearchFeed,
@@ -53,8 +62,9 @@ export function ResearchColumn({
<div className="deck-column-title">
<PlatformIcon platform={column.source.platform} />
<h2 id={headingId}>{column.title}</h2>
<button
className="icon-button"
<Button
variant="ghost"
size="icon-sm"
aria-label="更新"
title="更新"
type="button"
@@ -62,55 +72,46 @@ export function ResearchColumn({
disabled={query.isFetching}
>
<Icon name="refresh" />
</button>
<details className="column-menu">
<summary
className="icon-button"
aria-label={`${column.title}の操作`}
title="カラムの操作"
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={`${column.title}の操作`}
title="カラムの操作"
/>
}
>
<Icon name="more" />
</summary>
<div className="column-menu-panel">
<button type="button" onClick={onEdit}>
編集
</button>
<button
type="button"
onClick={(event) => {
onMove(-1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}>編集</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(-1)}
disabled={first}
aria-label={`${column.title}を左へ`}
>
← 左へ移動
</button>
<button
type="button"
onClick={(event) => {
onMove(1)
event.currentTarget
.closest('details')
?.removeAttribute('open')
}}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onMove(1)}
disabled={last}
aria-label={`${column.title}を右へ`}
>
右へ移動 →
</button>
<button
type="button"
className="danger"
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={onRemove}
aria-label={`${column.title}を削除`}
>
カラムを削除
</button>
</div>
</details>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="deck-column-label">
<span
@@ -118,10 +119,10 @@ export function ResearchColumn({
>
{connectionLabel ?? '接続が見つかりません'}
</span>
<span className="deck-source-label">
<Badge variant="outline" className="deck-source-label">
<Icon name={column.source.kind} />
{sourceDescription.label}
</span>
</Badge>
</div>
<p className="deck-query">{sourceDescription.query}</p>
<p className="deck-column-status" role="status">
@@ -152,7 +153,8 @@ export function ResearchColumn({
<div className="deck-message" role="alert">
<p>{error.message}</p>
{error.retryable ? (
<button
<Button
variant="outline"
type="button"
disabled={query.isFetching}
onClick={() =>
@@ -162,7 +164,7 @@ export function ResearchColumn({
}
>
再試行
</button>
</Button>
) : null}
</div>
) : null}
@@ -171,13 +173,14 @@ export function ResearchColumn({
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
<div className="deck-message">
<button
<Button
variant="outline"
type="button"
disabled={query.isFetching}
onClick={() => void query.fetchNextPage({ cancelRefetch: false })}
>
続きを読み込む
</button>
</Button>
</div>
) : null}
{!query.isPending &&
+342 -306
View File
@@ -5,7 +5,16 @@ import { useRef, useState } from 'react'
import { AppShell } from '#/components/app-shell'
import { Dialog } from '#/components/dialog'
import { Icon } from '#/components/icon'
import { ConnectionManager } from '#/features/connections/connection-manager'
import { Button } from '#/components/ui/button'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '#/components/ui/empty'
import { Input } from '#/components/ui/input'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel'
@@ -31,8 +40,9 @@ export function DeckPage() {
const researchDeckBindings = useRef(new Map<string, string>()).current
const [editing, setEditing] = useState<{ id: string } | 'new' | null>(null)
const [renaming, setRenaming] = 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 {
deck,
workspace,
@@ -83,6 +93,8 @@ export function DeckPage() {
})
function clearEditors() {
setDeckDialog(null)
setNewDeckTitle('')
setEditing(null)
setRenaming(false)
setRemoved(undefined)
@@ -102,312 +114,336 @@ export function DeckPage() {
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 (
<AppShell
researchChat={
<ResearchPanel
onNavigateCitation={citationNavigation.navigate}
ready={ready}
contextDeck={{
id: deck.id,
title: deck.title,
columns: deck.columns,
}}
connections={profiles.data?.connections ?? []}
onOpenDeck={async (plan, activate = false) => {
await syncResearchDeck(plan, activate, researchDeckBindings, {
getWorkspace,
createTemporary,
save,
select,
})
if (activate) clearEditors()
}}
/>
}
sidebar={
<>
<DeckSwitcher
key={deck.id}
decks={workspace.decks}
activeId={deck.id}
ready={ready && !saving}
onEditingChange={setSwitcherEditing}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
}}
/>
<button
className="deck-primary sidebar-add"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={!ready || saving || deck.columns.length >= MAX_COLUMNS}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
<button
type="button"
disabled={!ready}
onClick={() => setManagingConnections(true)}
>
接続アカウントを管理
</button>
</>
}
<ResearchPanel
onNavigateCitation={citationNavigation.navigate}
ready={ready}
contextDeck={{
id: deck.id,
title: deck.title,
columns: deck.columns,
}}
connections={profiles.data?.connections ?? []}
onOpenDeck={async (plan, activate = false) => {
await syncResearchDeck(plan, activate, researchDeckBindings, {
getWorkspace,
createTemporary,
save,
select,
})
if (activate) clearEditors()
}}
>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<button
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</button>
) : (
<button
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</button>
)}
<button
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</button>
</div>
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<button
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<button type="button" onClick={() => void profiles.refetch()}>
再試行
</button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{managingConnections ? (
<Dialog
title="接続アカウント"
onClose={() => setManagingConnections(false)}
>
<ConnectionManager
connections={profiles.data?.connections ?? []}
onChanged={() => profiles.refetch()}
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
/>
</Dialog>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{storageError ? (
<p className="deck-error" role="alert">
{storageError}
<button
type="button"
disabled={saving}
onClick={() => {
clearEditors()
void refresh(true)
}}
>
最新のデッキを再読み込み
</button>
</p>
) : null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await save({ ...deck, title })) setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
{(renderResearchPanel) => (
<AppShell
hasTemporaryDecks={workspace.decks.some((deck) => !deck.persisted)}
researchChat={renderResearchPanel}
sidebar={
<>
<DeckSwitcher
key={deck.id}
errorNotice={storageErrorNotice}
decks={workspace.decks}
activeId={deck.id}
ready={ready && !saving}
dialog={deckDialog}
newTitle={newDeckTitle}
onNewTitleChange={setNewDeckTitle}
onDialogChange={(dialog) => {
setDeckDialog(dialog)
if (dialog !== 'create') setNewDeckTitle('')
}}
onSelect={(id) => {
select(id)
clearEditors()
}}
onCreate={async (title) => {
const saved = await create(title)
if (saved) clearEditors()
return saved
}}
onDelete={async () => {
const saved = await remove(deck.id)
if (saved) clearEditors()
return saved
}}
/>
</label>
<button type="submit">名前を保存</button>
</form>
</Dialog>
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
<Button
className="w-full"
aria-label="カラムを追加"
title="カラムを追加"
type="button"
disabled={
!ready || saving || deck.columns.length >= MAX_COLUMNS
}
onClick={() => setEditing('new')}
>
<Icon name="plus" />
<span>カラムを追加</span>
</Button>
<p className="sidebar-note">
{deck.persisted
? 'サーバーに保存・端末間で共有'
: '一時ビュー · このタブのみ'}
</p>
</>
}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
</Dialog>
) : null}
{removed ? (
<div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span>
<button
type="button"
disabled={deck.columns.length >= MAX_COLUMNS || editing === 'new'}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
</button>
</div>
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<div className="deck-empty">
<Icon name="columns" />
<h2>調べたいことから、デッキを作りましょう</h2>
<p>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
</p>
<button
className="deck-primary"
type="button"
onClick={() => setEditing('new')}
>
手動でカラムを追加
</button>
</div>
) : null}
{ready && deck.columns.length > 0 ? (
// biome-ignore lint/a11y/noNoninteractiveTabindex: Keyboard users must be able to scroll the column board.
<section className="deck-board" aria-label="調査カラム" tabIndex={0}>
{deck.columns.map((column, index) => (
<ResearchColumn
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
citation={
citationNavigation.target?.deckId === deck.id &&
citationNavigation.target.column.id === column.id &&
sameCitationSource(citationNavigation.target.column, column)
? citationNavigation.target
: undefined
}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (typeof editing === 'object' && editing?.id === column.id)
setEditing(null)
}}
/>
))}
</section>
) : null}
</AppShell>
<div className="deck-heading">
<div>
<h1>{deck.title}</h1>
<span className="deck-count">
{deck.columns.length} / {MAX_COLUMNS} カラム
</span>
</div>
<div className="deck-actions">
{deck.persisted ? (
<Button
variant="outline"
type="button"
disabled={saving}
onClick={() => {
createTemporary(`${deck.title}のコピー`, deck.columns)
clearEditors()
}}
>
一時コピーを作成
</Button>
) : (
<Button
variant="outline"
type="button"
disabled={!ready || saving}
onClick={() => void persist(deck.id)}
>
デッキとして保存
</Button>
)}
<Button
variant="outline"
className="icon-button"
aria-label="名前を変更"
title="名前を変更"
type="button"
disabled={!ready || saving}
onClick={() => setRenaming(!renaming)}
>
<Icon name="edit" />
</Button>
</div>
</div>
{saving ? (
<p className="deck-save-status" role="status">
保存しています…
</p>
) : null}
{legacyAvailable ? (
<p className="deck-save-status">
このブラウザに旧デッキがあります。
<Button
variant="outline"
type="button"
disabled={saving || !profiles.data}
onClick={() => void importLegacy()}
>
旧デッキを取り込む
</Button>
</p>
) : null}
{!ready ? (
<p className="deck-save-status" role="status">
デッキを読み込んでいます…
</p>
) : null}
{profiles.isError ? (
<p role="alert" className="deck-error">
接続プロファイルを取得できませんでした。
<Button
variant="outline"
type="button"
onClick={() => void profiles.refetch()}
>
再試行
</Button>
</p>
) : null}
{oauthResult === 'failed' ? (
<p className="deck-error" role="alert">
Mastodonの認可を完了できませんでした。接続アカウントからやり直してください。
</p>
) : null}
{oauthResult === 'connected' ? (
<p className="deck-save-status" role="status">
Mastodonアカウントを接続しました。カラムの接続先に選べます。
</p>
) : null}
{profiles.data?.relayError ? (
<p className="deck-error" role="alert">
{profiles.data.relayError}
</p>
) : null}
{profiles.isPending ? (
<p role="status">接続プロファイルを取得しています…</p>
) : null}
{!renaming && editing === null && !switcherEditing
? storageErrorNotice
: null}
{renaming ? (
<Dialog title="デッキ名を変更" onClose={() => setRenaming(false)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
if (!title) return
if (await save({ ...deck, title })) setRenaming(false)
}}
>
<label htmlFor="deck-title">
調査テーマ・デッキ名
<Input
id="deck-title"
name="title"
defaultValue={deck.title}
maxLength={120}
required
/>
</label>
<Button variant="outline" type="submit">
名前を保存
</Button>
</form>
{storageErrorNotice}
</Dialog>
) : null}
{editing !== null ? (
<Dialog
title={editing === 'new' ? 'カラムを追加' : 'カラムを編集'}
onClose={() => setEditing(null)}
>
<ColumnEditor
key={`${deck.id}-${editing === 'new' ? 'new' : editing.id}`}
connections={profiles.data?.connections ?? []}
column={
editing === 'new'
? undefined
: deck.columns.find((column) => column.id === editing.id)
}
onSave={saveColumn}
onCancel={() => setEditing(null)}
/>
{storageErrorNotice}
</Dialog>
) : null}
{removed ? (
<div className="deck-undo" role="status">
<span>「{removed.column.title}」を削除しました。</span>
<Button
variant="outline"
type="button"
disabled={
deck.columns.length >= MAX_COLUMNS || editing === 'new'
}
onClick={async () => {
const columns = [...deck.columns]
columns.splice(removed.index, 0, removed.column)
if (await save({ ...deck, columns })) setRemoved(undefined)
}}
>
元に戻す
</Button>
</div>
) : null}
{ready && deck.columns.length === 0 && editing === null ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon name="columns" />
</EmptyMedia>
<EmptyTitle>
<h2>調べたいことから、デッキを作りましょう</h2>
</EmptyTitle>
<EmptyDescription>
チャットでAIに調べたいことを伝えると、調査に合ったカラムを作成します。
検索・ユーザー投稿・リストのカラムを手動で追加することもできます。
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button type="button" onClick={() => setEditing('new')}>
手動でカラムを追加
</Button>
</EmptyContent>
</Empty>
) : null}
{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.
tabIndex={0}
>
{deck.columns.map((column, index) => (
<ResearchColumn
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
citation={
citationNavigation.target?.deckId === deck.id &&
citationNavigation.target.column.id === column.id &&
sameCitationSource(citationNavigation.target.column, column)
? citationNavigation.target
: undefined
}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
)?.displayName
}
first={index === 0}
last={index === deck.columns.length - 1}
onEdit={() => setEditing({ id: column.id })}
onMove={(direction) =>
save(moveColumn(deck, column.id, direction))
}
onRemove={async () => {
const saved = await save({
...deck,
columns: deck.columns.filter(
(current) => current.id !== column.id,
),
})
if (saved) setRemoved({ column, index })
if (
typeof editing === 'object' &&
editing?.id === column.id
)
setEditing(null)
}}
/>
))}
</section>
) : null}
</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 { 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'
export function DeckSwitcher({
@@ -10,7 +14,11 @@ export function DeckSwitcher({
onSelect,
onCreate,
onDelete,
onEditingChange,
dialog,
onDialogChange,
newTitle,
onNewTitleChange,
errorNotice,
}: {
decks: DeckView[]
activeId: string
@@ -18,109 +26,118 @@ export function DeckSwitcher({
onSelect: (id: string) => void
onCreate: (title: string) => 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 (
<div className="deck-workspaces">
<div className="deck-actions">
<label htmlFor="deck-selection">デッキプロファイル</label>
<select
<Label htmlFor="deck-selection">デッキプロファイル</Label>
<NativeSelect
className="w-full"
id="deck-selection"
value={activeId}
disabled={!ready}
onChange={(event) => {
onSelect(event.target.value)
setCreating(false)
setDeleting(false)
onDialogChange(null)
}}
>
{decks.map((deck) => (
<option key={deck.id} value={deck.id}>
<NativeSelectOption key={deck.id} value={deck.id}>
{deck.title}
{deck.persisted ? '' : '(一時)'}
</option>
</NativeSelectOption>
))}
</select>
<button
</NativeSelect>
<Button
variant="outline"
className="workspace-create"
aria-label="デッキを作成"
title="デッキを作成"
type="button"
disabled={!ready}
onClick={() => {
setCreating(!creating)
setDeleting(false)
onDialogChange('create')
}}
>
<Icon name="plus" />
<span>デッキを作成</span>
</button>
<button
</Button>
<Button
variant="ghost"
className="workspace-delete"
aria-label="デッキを削除"
title="デッキを削除"
type="button"
disabled={!ready}
onClick={() => {
setDeleting(!deleting)
setCreating(false)
onDialogChange('delete')
}}
>
<Icon name="trash" />
<span>デッキを削除</span>
</button>
</Button>
</div>
{creating ? (
<Dialog title="デッキを作成" onClose={() => setCreating(false)}>
{dialog === 'create' ? (
<Dialog title="デッキを作成" onClose={() => onDialogChange(null)}>
<form
className="deck-rename"
onSubmit={async (event) => {
event.preventDefault()
const title = String(
new FormData(event.currentTarget).get('title') ?? '',
).trim()
const title = newTitle.trim()
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"
name="title"
value={newTitle}
onChange={(event) => onNewTitleChange(event.target.value)}
maxLength={120}
required
/>
</label>
<button type="submit">作成</button>
<button type="button" onClick={() => setCreating(false)}>
</Label>
<Button type="submit">作成</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
</button>
</Button>
</form>
{errorNotice}
</Dialog>
) : null}
{deleting ? (
<Dialog title="デッキを削除" onClose={() => setDeleting(false)}>
{dialog === 'delete' ? (
<Dialog title="デッキを削除" onClose={() => onDialogChange(null)}>
<div className="deck-undo">
<p>このデッキとカラムの設定を削除します。</p>
<button
<Button
variant="destructive"
type="button"
onClick={async () => {
if (await onDelete()) setDeleting(false)
if (await onDelete()) onDialogChange(null)
}}
>
削除する
</button>
<button type="button" onClick={() => setDeleting(false)}>
</Button>
<Button
variant="outline"
type="button"
onClick={() => onDialogChange(null)}
>
キャンセル
</button>
</Button>
</div>
{errorNotice}
</Dialog>
) : null}
</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 {
display: flex;
align-items: center;
@@ -61,7 +17,7 @@
}
.deck-heading p {
margin: var(--space-2xs) 0 0;
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-actions {
@@ -73,7 +29,7 @@
.deck-help,
.deck-save-status,
.deck-column-status {
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
line-height: 1.5;
}
@@ -130,7 +86,7 @@
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-label span {
@@ -149,7 +105,7 @@
.deck-query {
margin: var(--space-2xs) 0;
font-size: var(--text-xs);
color: var(--secondary);
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -166,47 +122,9 @@
scrollbar-width: thin;
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 {
padding: var(--space-md);
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-sm);
line-height: 1.6;
}
@@ -223,11 +141,6 @@
.research-post header > div {
min-width: 0;
}
.research-post header img {
flex-shrink: 0;
border-radius: var(--radius-sm);
object-fit: cover;
}
.research-post strong {
font-size: var(--text-sm);
}
@@ -250,11 +163,11 @@
justify-content: space-between;
gap: var(--space-xs);
margin-top: var(--space-sm);
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post a {
color: var(--accent);
color: var(--primary);
text-underline-offset: 0.2em;
}
.research-media {
@@ -265,34 +178,6 @@
margin-block: var(--space-xs);
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 {
margin: 0;
padding: 0;
@@ -312,30 +197,12 @@
font-size: var(--text-sm);
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 {
display: flex;
align-items: center;
min-height: 40px;
margin-bottom: var(--space-sm);
}
.deck-checkbox input {
accent-color: var(--accent);
width: 1.15rem;
height: 1.15rem;
}
.deck-rename {
display: flex;
flex-wrap: wrap;
@@ -347,7 +214,7 @@
flex: 1 1 100%;
}
.deck-error {
color: var(--error);
color: var(--destructive);
font-size: var(--text-sm);
line-height: 1.6;
overflow-wrap: anywhere;
@@ -374,7 +241,7 @@
padding: var(--space-lg);
}
.deck-empty p {
color: var(--secondary);
color: var(--muted-foreground);
line-height: 1.7;
margin-bottom: var(--space-lg);
}
@@ -386,10 +253,10 @@
}
.deck-workspaces > .deck-actions > label {
width: 100%;
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-workspaces > .deck-actions > select {
.deck-workspaces > .deck-actions > [data-slot="native-select-wrapper"] {
flex: 1 1 100%;
}
.deck-workspaces > .deck-actions > button {
@@ -404,35 +271,6 @@
width: 100%;
}
@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 {
min-height: 52px;
padding-inline: var(--space-sm);
@@ -444,37 +282,19 @@
grid-auto-columns: min(328px, 100%);
}
}
@media (pointer: coarse) {
.app-deck .icon-button,
.column-menu summary {
min-width: 40px;
min-height: 40px;
}
}
.deck-count {
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.research-post header time {
display: block;
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
.deck-column-status {
text-align: right;
}
@media (max-width: 767px) {
.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 {
.research-post header > div:not([data-slot="avatar"]) {
flex: 1;
}
.research-post header time {
@@ -490,6 +310,3 @@
width: 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 { useServerFn } from '@tanstack/react-start'
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 type { MastodonSource } from '../platforms/mastodon-source'
@@ -32,9 +36,10 @@ export function MastodonSourceEditor({
const id = useId()
return (
<>
<label htmlFor={id}>
<Label htmlFor={id}>
カラムの種類
<select
<NativeSelect
className="w-full"
id={id}
value={source.kind}
onChange={(event) =>
@@ -45,12 +50,12 @@ export function MastodonSourceEditor({
)
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
<option value="hashtag">ハッシュタグ</option>
</select>
</label>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
<NativeSelectOption value="hashtag">ハッシュタグ</NativeSelectOption>
</NativeSelect>
</Label>
<MastodonSourceFields
source={source}
connectionId={connectionId}
@@ -90,9 +95,9 @@ function MastodonSearchEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
Mastodonの検索条件
<input
<Input
id={id}
value={source.query}
onChange={(event) =>
@@ -102,7 +107,7 @@ function MastodonSearchEditor({
placeholder="例:WebMCP"
required
/>
</label>
</Label>
<p className="deck-help">
全文検索の対象はサーバーの設定によって異なります。結果が0件でも、検索に対応しているとは限りません。
</p>
@@ -117,9 +122,9 @@ function MastodonUserEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
ユーザー
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -129,7 +134,7 @@ function MastodonUserEditor({
maxLength={256}
required
/>
</label>
</Label>
</div>
)
}
@@ -141,9 +146,9 @@ function MastodonHashtagEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
ハッシュタグ
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -153,7 +158,7 @@ function MastodonHashtagEditor({
maxLength={128}
required
/>
</label>
</Label>
</div>
)
}
@@ -168,9 +173,9 @@ function MastodonListEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
リスト
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -180,7 +185,7 @@ function MastodonListEditor({
maxLength={32}
required
/>
</label>
</Label>
{connectionId && (
<MastodonListChoices
connectionId={connectionId}
@@ -209,9 +214,13 @@ function MastodonListChoices({
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
</button>
</Button>
</p>
)
return (
@@ -220,9 +229,14 @@ function MastodonListChoices({
<p>このアカウントのリストはありません。</p>
) : (
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}
</button>
</Button>
))
)}
</section>
+14 -12
View File
@@ -1,6 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { Icon } from '#/components/icon'
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'
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
@@ -49,20 +52,17 @@ export function ResearchPostCard({
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
data-post-url={post.url}
>
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
{citationSnapshot ? (
<Badge variant="secondary">調査時に取得した投稿</Badge>
) : null}
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
) : null}
<header>
{post.author.avatarUrl ? (
<img
src={post.author.avatarUrl}
alt=""
width={36}
height={36}
loading="lazy"
/>
) : null}
<Avatar className="size-9 shrink-0">
<AvatarImage src={post.author.avatarUrl} alt="" loading="lazy" />
<AvatarFallback>{post.author.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<div>
<strong>{post.author.name}</strong>
<span className="handle">
@@ -78,13 +78,15 @@ export function ResearchPostCard({
{concealed ? (
<div>
<p>{post.contentWarning || 'センシティブなメディアを含む投稿'}</p>
<button
<Button
variant="outline"
size="sm"
type="button"
aria-expanded={revealed}
onClick={() => setRevealed(!revealed)}
>
{revealed ? '内容を隠す' : '内容を表示'}
</button>
</Button>
</div>
) : null}
{!concealed || revealed ? (
+47 -32
View File
@@ -1,4 +1,9 @@
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 type { DeckColumn } from './model'
@@ -33,20 +38,21 @@ export function TwitterSourceEditor({
const id = useId()
return (
<>
<label htmlFor={id}>
<Label htmlFor={id}>
カラムの種類
<select
<NativeSelect
className="w-full"
id={id}
value={source.kind}
onChange={(event) =>
onChange(defaultTwitterSource(event.target.value as Source['kind']))
}
>
<option value="search">検索</option>
<option value="user">ユーザー投稿</option>
<option value="list">リスト</option>
</select>
</label>
<NativeSelectOption value="search">検索</NativeSelectOption>
<NativeSelectOption value="user">ユーザー投稿</NativeSelectOption>
<NativeSelectOption value="list">リスト</NativeSelectOption>
</NativeSelect>
</Label>
<TwitterSourceFields
source={source}
connectionId={connectionId}
@@ -84,9 +90,9 @@ function TwitterSearchEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={`${id}-query`}>
<Label htmlFor={`${id}-query`}>
Twitterの検索条件
<input
<Input
id={`${id}-query`}
value={source.query}
onChange={(event) =>
@@ -96,10 +102,11 @@ function TwitterSearchEditor({
placeholder="例:WebMCP lang:ja"
required
/>
</label>
<label htmlFor={`${id}-product`}>
</Label>
<Label htmlFor={`${id}-product`}>
表示順
<select
<NativeSelect
className="w-full"
id={`${id}-product`}
value={source.product}
onChange={(event) =>
@@ -109,23 +116,22 @@ function TwitterSearchEditor({
})
}
>
<option value="Latest">最新</option>
<option value="Top">話題</option>
</select>
</label>
<NativeSelectOption value="Latest">最新</NativeSelectOption>
<NativeSelectOption value="Top">話題</NativeSelectOption>
</NativeSelect>
</Label>
<p className="deck-help">
from:、lang:、since: など、Twitterの検索構文を使えます。
</p>
<label className="deck-checkbox">
<input
type="checkbox"
<Label className="deck-checkbox">
<Checkbox
checked={source.following}
onChange={(event) =>
onChange({ ...source, following: event.target.checked })
onCheckedChange={(checked) =>
onChange({ ...source, following: checked })
}
/>
フォロー中のみ
</label>
</Label>
</div>
)
}
@@ -137,9 +143,9 @@ function TwitterUserEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
ユーザー
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -149,7 +155,7 @@ function TwitterUserEditor({
maxLength={256}
required
/>
</label>
</Label>
</div>
)
}
@@ -162,9 +168,9 @@ function TwitterListEditor({
const id = useId()
return (
<div className="deck-source-fields">
<label htmlFor={id}>
<Label htmlFor={id}>
リスト
<input
<Input
id={id}
value={source.target}
onChange={(event) =>
@@ -174,7 +180,7 @@ function TwitterListEditor({
maxLength={256}
required
/>
</label>
</Label>
{connectionId && (
<TwitterListChoices
connectionId={connectionId}
@@ -198,9 +204,13 @@ function TwitterListChoices({
return (
<p role="alert">
リストを取得できませんでした。
<button type="button" onClick={() => void lists.refetch()}>
<Button
variant="outline"
type="button"
onClick={() => void lists.refetch()}
>
再試行
</button>
</Button>
</p>
)
return (
@@ -212,10 +222,15 @@ function TwitterListChoices({
<p>リストはありません。URLまたはIDでも指定できます。</p>
) : (
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.isPrivate ? ' · 非公開' : ''}
</button>
</Button>
))
)}
</section>
+146
View File
@@ -0,0 +1,146 @@
import { ArrowLeft, Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarProvider,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { type Article, articles } from './mock-articles'
import './inbox.css'
export function InboxPage() {
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<string | undefined>(
articles[0]?.id,
)
const [readingMobile, setReadingMobile] = useState(false)
const visible = articles.filter((article) =>
`${article.title} ${article.summary} ${article.source}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
)
const selected =
visible.find((article) => article.id === selectedId) ?? visible[0]
return (
<TooltipProvider delay={500}>
<SidebarProvider className="inbox-workspace" data-reading={readingMobile}>
<Sidebar collapsible="none" className="inbox-sidebar">
<WorkspaceNavigation />
<Sidebar collapsible="none" className="inbox-list-panel">
<SidebarHeader className="gap-4 border-b p-5">
<h1 className="text-base font-semibold">リーダー</h1>
<div className="relative">
<Search
aria-hidden="true"
className="pointer-events-none absolute left-3 top-2 size-4 text-muted-foreground"
/>
<Input
aria-label="記事を検索"
placeholder="検索"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</SidebarHeader>
<SidebarContent className="gap-0" aria-label="記事一覧">
{visible.map((article) => (
<ArticleRow
key={article.id}
article={article}
selected={selected?.id === article.id}
onSelect={() => {
setSelectedId(article.id)
setReadingMobile(true)
}}
/>
))}
{visible.length === 0 && (
<div className="inbox-no-results">
<p>記事がありません</p>
<Button variant="outline" onClick={() => setQuery('')}>
検索をクリア
</Button>
</div>
)}
</SidebarContent>
</Sidebar>
</Sidebar>
<SidebarInset className="inbox-reader">
<header className="inbox-reader-toolbar">
<Button
variant="ghost"
size="icon"
className="inbox-back"
aria-label="記事一覧に戻る"
onClick={() => setReadingMobile(false)}
>
<ArrowLeft />
</Button>
<span className="text-xs text-muted-foreground">
{selected?.source}
</span>
</header>
<section
className="inbox-reader-scroll"
key={selected?.id}
aria-label="記事本文"
// biome-ignore lint/a11y/noNoninteractiveTabindex: The independently scrolling reader must be keyboard-scrollable.
tabIndex={0}
>
{selected && <ArticleReader article={selected} />}
</section>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
function ArticleRow({
article,
selected,
onSelect,
}: {
article: Article
selected: boolean
onSelect: () => void
}) {
return (
<button
type="button"
className="inbox-article-row"
aria-current={selected ? 'true' : undefined}
onClick={onSelect}
>
<span className="inbox-row-meta">
<span className="truncate">{article.source}</span>
<time className="ml-auto shrink-0">{article.date}</time>
</span>
<span className="inbox-row-title">{article.title}</span>
<span className="inbox-row-summary">{article.summary}</span>
</button>
)
}
function ArticleReader({ article }: { article: Article }) {
return (
<article className="inbox-article" aria-label={article.title}>
<h2 className="inbox-article-title">{article.title}</h2>
<p className="inbox-byline text-xs text-muted-foreground">
{article.author} · {article.date}
</p>
<div className="inbox-article-body">
{article.paragraphs.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</article>
)
}
+148
View File
@@ -0,0 +1,148 @@
/* Hallmark · macrostructure: sidebar-09 Inbox · tone: reading-first · anchor hue: neutral */
/* Hallmark · pre-emit critique: P5 H5 E4 S5 R5 V4 */
.inbox-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.inbox-sidebar {
width: 420px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.inbox-list-panel {
flex: 1;
min-width: 0;
width: auto;
}
.inbox-article-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 11px;
width: 100%;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
}
.inbox-article-row:hover {
background: var(--sidebar-accent);
}
.inbox-article-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.inbox-row-meta {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--muted-foreground);
font-size: 11px;
}
.inbox-row-title {
display: block;
font-size: 14px;
font-weight: 600;
line-height: 1.65;
text-wrap: pretty;
}
.inbox-row-summary {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
line-height: 1.8;
}
.inbox-reader {
min-width: 0;
height: 100%;
overflow: hidden;
}
.inbox-reader-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 60px;
padding: 10px 26px;
border-bottom: 1px solid var(--border);
}
.inbox-reader-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.inbox-article {
max-width: 800px;
margin: 0 auto;
padding: 54px 52px 40px;
}
.inbox-article-title {
margin: 0;
font-size: clamp(24px, 2.4vw, 34px);
line-height: 1.55;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
text-wrap: pretty;
}
.inbox-byline {
margin-block: 30px;
}
.inbox-article-body {
font-size: 15px;
line-height: 2.15;
letter-spacing: 0.015em;
}
.inbox-article-body p + p {
margin-top: 24px;
}
.inbox-no-results {
display: grid;
justify-items: start;
gap: 12px;
padding: 32px 20px;
color: var(--muted-foreground);
font-size: 13px;
line-height: 1.8;
}
.inbox-back {
display: none;
}
@media (min-width: 768px) and (max-width: 1100px) {
.inbox-sidebar {
width: 340px;
}
.inbox-article {
padding: 36px 28px;
}
}
@media (max-width: 767px) {
.inbox-sidebar {
width: 100%;
border-right: 0;
}
.inbox-reader {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-sidebar {
display: none;
}
.inbox-workspace[data-reading="true"] .inbox-reader {
display: flex;
}
.inbox-back {
display: inline-flex;
}
.inbox-reader-toolbar {
padding-inline: 12px;
}
.inbox-article {
padding: 30px 24px;
}
}
+138
View File
@@ -0,0 +1,138 @@
export type Article = {
id: string
title: string
summary: string
source: string
author: string
date: string
paragraphs: string[]
}
// All articles are original mock content. Editorial names are fictional.
export const articles: Article[] = [
{
id: 'agent-browser-workflow',
title: 'ブラウザを見ながら考える。AIエージェントとの新しい開発の進め方',
summary:
'コードを書いて終わりにしない。画面を開き、操作し、確かめるところまでをひとつの作業にする。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '09:40',
paragraphs: [
'UIをつくるとき、コードが正しいことと、画面が使いやすいことの間には距離がある。ボタンが画面の外に出ていないか。入力した文章は画面を切り替えても残るか。こうした問いへの答えは、実際にブラウザを動かして初めて見えてくる。',
'そこで試したいのが、実装と確認をひとつの短い循環にする進め方だ。エージェントに変更を依頼するとき、「記事を選び、詳細を読み、一覧に戻る」といった利用者の動作も添える。作業の終点がファイルの変更から、動作の確認へと具体的になる。',
'確認には、ふだんの画面幅と狭い画面幅の両方を使う。うまく動いた結果だけでなく、何を操作し、何を確認できなかったかも残しておくと、次の変更で同じ箇所を確かめやすい。最初から網羅するより、重要な操作をひとつ確実に通すことから始めたい。',
'人が担うのは、その体験が目的に合っているかを判断すること。操作の繰り返しをエージェントに任せると、余白や言葉、情報の順序といった、使い心地に向き合う時間を取りやすくなる。',
],
},
{
id: 'small-cli-tools',
title: '毎日使いたくなるCLIは、終了時のひと言まで設計されている',
summary:
'ヘルプ、エラー、実行結果。小さな道具の使い心地は、出力の細部で変わる。',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '09:15',
paragraphs: [
'コマンドを実行したあと、何が起きたのか分からない。これは処理そのものが成功していても、使う側には不安が残る体験だ。作成したファイルの場所や処理件数を一行で伝えるだけで、道具とのやり取りはずっと明快になる。',
'エラーも、原因と次の行動が近くにあると扱いやすい。「設定が不正です」より、「出力先が見つかりません。存在するディレクトリを指定してください」のほうが、調べ直す手間を減らせる。内部の事情は必要なときにだけ表示できればよい。',
'人が読む出力と、別のプログラムが読む出力の用途も分けて考えたい。通常は簡潔な文章を返し、構造化された結果が必要なら専用の形式を選べるようにする。小さなCLIほど、こうした一貫性が長く使う理由になる。',
],
},
{
id: 'three-column-reading',
title: '3カラムの余白が、情報を読むリズムをつくる',
summary:
'場所を選ぶ、候補を眺める、ひとつを読む。役割が分かれた画面について。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '08:50',
paragraphs: [
'記事を探すとき、私たちはすべての文章を同じ深さで読んでいるわけではない。関心のある分野を選び、見出しを流し読みし、気になるものにだけ腰を据える。3カラムの画面は、この異なる読み方にそれぞれの場所を用意できる。',
'左は現在地、中央は候補、右は本文。役割が明確なら、記事を開いても一覧の中での位置を見失いにくい。選択中の項目を穏やかに示すことや、一覧のスクロール位置を保つことが、この安心感を支えている。',
'すべてのカラムを等しい幅にする必要はない。見出しを判断できる一覧の幅を確保し、残りを本文に渡す。本文にも適切な最大幅を設ければ、大きな画面で一行が長くなりすぎることを防げる。',
'狭い画面では、同じ役割を順番に見せればよい。一覧と本文を切り替えても、戻る場所が明確であること。カラムの数よりも、読む途中の文脈が続いていることが大切だ。',
],
},
{
id: 'agent-context',
title: 'エージェントに渡す文脈は、短くてもよい',
summary:
'大量の説明よりも、目的・制約・完了条件をそろえる。依頼を整理する小さな習慣。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '08:30',
paragraphs: [
'依頼を詳しく書こうとするほど、背景と希望と必須条件が混ざってしまうことがある。受け手がエージェントでも人でも、最初に知りたいのは何を実現する作業なのかという一点だ。',
'まず目的を一文で書き、次に変えてはいけない条件を添える。最後に、何を確かめたら作業が終わるのかを決める。参考ファイルやスクリーンショットは、その三つを補うために渡すと役割がはっきりする。',
'途中で判断が変わったときは、説明を足し続けるより、現在の方針を短く書き直すほうが役立つ場合がある。文脈の量ではなく、いま有効な判断を見つけやすいことを意識したい。',
],
},
{
id: 'local-first-notes',
title: 'ローカルで完結する、小さな情報収集ワークスペース',
summary:
'集めることより、あとから見つけられること。ファイルを中心にした道具の組み合わせ。',
source: 'Workbench Journal',
author: 'Workbench Journal 編集部',
date: '昨日',
paragraphs: [
'気になった記事を保存する場所は増えがちだ。ブラウザのブックマーク、メモアプリ、チャットの自分宛てメッセージ。集める入口が便利でも、読み返す入口が分かれていると、保存したこと自体を忘れてしまう。',
'小さく始めるなら、タイトル、出典、保存した理由だけをテキストで残す方法がある。本文をすべて保管する前に、なぜ自分が気になったのかを一行添えておく。その一行が、検索したときの判断材料になる。',
'ローカルのファイルを中心にすると、使い慣れた検索やエディタと組み合わせやすい。一方、複数の端末で扱うなら同期や競合の扱いを決める必要がある。道具を増やす前に、どこで読み、いつ振り返るのかを決めておきたい。',
],
},
{
id: 'quiet-design-system',
title: 'デザインシステムを、使う人の言葉で整える',
summary: 'コンポーネントをそろえた先にある、ラベルや空状態の一貫性。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '昨日',
paragraphs: [
'ボタンの角丸や色がそろっていても、同じ操作を画面ごとに違う言葉で呼んでいると迷いが生まれる。「保存」と「追加」が何を意味するのか。見た目と同じように、言葉にも共通のルールが必要だ。',
'最初に整理したいのは、よく使う操作と状態の名前だ。未読、保存済み、読み込み中などの基本的な表現を決めておくと、新しい画面をつくるときの判断が少なくなる。専門用語を画面に出す前に、使う人がその違いを知る必要があるかも考えたい。',
'空の一覧にも役割がある。何もない理由と、次にできることが簡潔に分かれば、空状態は行き止まりにならない。派手なイラストがなくても、適切な一文と操作があれば十分なことは多い。',
],
},
{
id: 'personal-feed',
title: 'おすすめの理由が見える、パーソナルな記事フィード',
summary:
'「なぜこの記事?」に答えられることが、興味に基づく推薦の出発点になる。',
source: 'Field Notes',
author: 'Field Notes 編集部',
date: '昨日',
paragraphs: [
'興味に合う記事が届くと便利だが、その理由が分からないと、偶然なのか自分の行動が反映されたのか判断しづらい。推薦の根拠を短く添えるだけで、読む側は自分との接点を見つけやすくなる。',
'理由は複雑なスコアである必要はない。「選んだトピックに関連」「保存した記事と近いテーマ」など、実際に使っている情報と対応していることが大切だ。まだ推薦処理がない試作なら、その理由もサンプルであることを明示しておく。',
'関心は変化するので、利用者が調整できる入口も必要になる。トピックの追加や解除、読みたくない記事の非表示など、小さな操作から始められる。よいフィードは、当て続ける仕組みだけでなく、外れたときに直せる仕組みでもある。',
],
},
{
id: 'behavior-tests',
title: 'テストに残したいのは、実装よりも利用者の約束',
summary: '内部の構造を変えても守りたい動作を、短いシナリオで表す。',
source: 'Toolbox Letter',
author: 'Toolbox Letter 編集部',
date: '2日前',
paragraphs: [
'コンポーネントを分割しただけなのに、多くのテストが壊れる。そんなときは、テストが利用者の操作よりも内部の構造に寄り添っていないかを見直したい。守りたいのは、どの関数が呼ばれたかではなく、期待する結果が得られたかという約束だ。',
'記事一覧なら、「トピックを選ぶと該当する記事が並ぶ」「記事を保存すると保存済みで見つけられる」といったシナリオを考えられる。画面に表示される名前で操作して、結果も画面から確かめると、何を保証するテストかが読み取りやすい。',
'もちろん、すべてをブラウザで確かめる必要はない。複雑な絞り込みの計算は小さな単位で検証し、代表的な操作の流れを画面で確認する。変更の影響に合わせて確認の範囲を選ぶことが、続けやすいテストにつながる。',
],
},
{
id: 'reading-typography',
title: '読みやすい本文は、文字サイズだけでは決まらない',
summary: '行の長さ、行間、段落の間隔。読み進めやすい記事画面のためのメモ。',
source: 'Quiet Interface',
author: 'Quiet Interface 編集部',
date: '2日前',
paragraphs: [
'文字が十分に大きくても、一行が長すぎると次の行を見つけるのが難しくなる。逆に幅が狭すぎると、短い単語のまとまりまで細かく折り返され、読むリズムが途切れる。本文の幅は、文字サイズと一緒に考えたい。',
'行間は一行ごとの読みやすさを、段落の間隔は話のまとまりを支えている。同じ余白として扱わず、それぞれの役割が見えるように調整すると、装飾を増やさなくても文章の構造が伝わる。',
'調整するときは、短いダミーテキストだけで判断しない。長い見出しや複数段落の記事を置き、実際に数分読んでみる。画面の見栄えだけでは気づけない、読み疲れや視線の迷いを見つけやすくなる。',
],
},
]
+349
View File
@@ -0,0 +1,349 @@
import { useId, useState } from 'react'
import { Dialog } from '#/components/dialog'
import { Button } from '#/components/ui/button'
import { Checkbox } from '#/components/ui/checkbox'
import { Input } from '#/components/ui/input'
import { SidebarInset, SidebarProvider } from '#/components/ui/sidebar'
import { Textarea } from '#/components/ui/textarea'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import './journal.css'
type EntryKind = 'メモ' | '再開地点' | '服薬' | '体重'
type Entry = {
id: string
kind: EntryKind
text: string
source: string
occurredAt: string
recordedAt: string
correctionOf?: string
}
type Draft = Pick<Entry, 'kind' | 'text' | 'source' | 'occurredAt'>
const emptyDraft: Draft = { kind: 'メモ', text: '', source: '', occurredAt: '' }
const kinds: EntryKind[] = ['メモ', '再開地点', '服薬', '体重']
function formatTime(value: string | undefined) {
if (!value) return '不明'
return new Date(value).toLocaleString('ja-JP', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function JournalPage() {
const [entries, setEntries] = useState<Entry[]>([])
const [draft, setDraft] = useState<Draft>(emptyDraft)
const [correcting, setCorrecting] = useState<Entry | null>(null)
const [reviewing, setReviewing] = useState(false)
const [selected, setSelected] = useState<Set<string>>(new Set())
const superseded = new Set(entries.map((entry) => entry.correctionOf))
const current = entries.filter((entry) => !superseded.has(entry.id))
function append(value: Draft, correctionOf?: string) {
setEntries((previous) => [
{
...value,
text: value.text.trim(),
source: value.source.trim(),
id: crypto.randomUUID(),
recordedAt: new Date().toISOString(),
correctionOf,
},
...previous,
])
}
return (
<TooltipProvider delay={500}>
<SidebarProvider className="journal-workspace">
<WorkspaceNavigation />
<SidebarInset className="journal-main">
<header className="journal-header">
<h1 className="text-base font-semibold">記録</h1>
<Button
variant="outline"
disabled={current.length === 0}
onClick={() => {
setSelected(new Set(current.map((entry) => entry.id)))
setReviewing(true)
}}
>
記録をまとめる
</Button>
</header>
<div className="journal-scroll">
<div className="journal-column">
<EntryEditor
draft={draft}
onChange={setDraft}
onSubmit={() => {
append(draft)
setDraft(emptyDraft)
}}
submitLabel="記録する"
/>
<section className="journal-entries" aria-label="記録一覧">
{entries.length === 0 && (
<p className="journal-empty">まだ記録はありません</p>
)}
{entries.map((entry) => (
<article
key={entry.id}
className="journal-entry"
aria-label={`${entry.kind}の記録`}
>
<div className="journal-entry-heading">
<span className="text-sm font-medium">{entry.kind}</span>
{superseded.has(entry.id) && (
<span className="text-xs text-muted-foreground">
訂正前
</span>
)}
{entry.correctionOf && (
<span className="text-xs text-muted-foreground">
訂正
</span>
)}
{!superseded.has(entry.id) && (
<Button
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => setCorrecting(entry)}
>
訂正する
</Button>
)}
</div>
<p className="journal-entry-text">
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
</p>
<p className="text-xs text-muted-foreground">
発生日時: {formatTime(entry.occurredAt)} · 記録日時:{' '}
{formatTime(entry.recordedAt)}
</p>
{(entry.kind === '体重' || entry.source) && (
<p className="mt-2 text-xs text-muted-foreground">
記録元: {entry.source || '不明'}
</p>
)}
</article>
))}
</section>
</div>
</div>
</SidebarInset>
</SidebarProvider>
{correcting && (
<CorrectionDialog
entry={correcting}
onClose={() => setCorrecting(null)}
onSave={(value) => {
append(value, correcting.id)
setCorrecting(null)
}}
/>
)}
{reviewing && (
<Dialog
title="記録をまとめる"
className="sm:max-w-2xl"
onClose={() => setReviewing(false)}
>
<p className="text-sm text-muted-foreground">
含める記録を選んで確認できます。外部には送信されません。
</p>
<div className="journal-review-list">
{current.map((entry) => (
<label
key={entry.id}
className="journal-review-row"
htmlFor={`review-${entry.id}`}
>
<Checkbox
id={`review-${entry.id}`}
checked={selected.has(entry.id)}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous)
if (checked) next.add(entry.id)
else next.delete(entry.id)
return next
})
}
/>
<span>
<span className="text-xs text-muted-foreground">
{entry.kind} · {formatTime(entry.occurredAt)}
</span>
<span className="block text-sm">
{entry.text}
{entry.kind === '体重' ? ' kg' : ''}
</span>
</span>
</label>
))}
</div>
<Textarea
aria-label="記録のまとめ"
className="journal-summary"
readOnly
value={buildSummary(
current.filter((entry) => selected.has(entry.id)),
)}
/>
</Dialog>
)}
</TooltipProvider>
)
}
function EntryEditor({
draft,
onChange,
onSubmit,
submitLabel,
}: {
draft: Draft
onChange: (draft: Draft) => void
onSubmit: () => void
submitLabel: string
}) {
const dateId = useId()
const valid =
draft.text.trim().length > 0 &&
(draft.kind !== '体重' ||
(Number.isFinite(Number(draft.text)) && Number(draft.text) > 0))
return (
<form
className="journal-editor"
onSubmit={(event) => {
event.preventDefault()
if (valid) onSubmit()
}}
>
<fieldset className="journal-kind-switch" aria-label="記録の種類">
{kinds.map((kind) => (
<Button
key={kind}
type="button"
variant={draft.kind === kind ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={draft.kind === kind}
onClick={() => onChange({ ...draft, kind })}
>
{kind}
</Button>
))}
</fieldset>
{draft.kind === '体重' ? (
<div className="flex items-center gap-2">
<Input
type="number"
step="any"
min="0.1"
aria-label="体重"
placeholder="体重"
value={draft.text}
onChange={(event) =>
onChange({ ...draft, text: event.target.value })
}
/>
<span className="text-sm text-muted-foreground">kg</span>
</div>
) : (
<Textarea
aria-label="記録内容"
placeholder={
draft.kind === '服薬'
? '服用した薬の名前'
: draft.kind === '再開地点'
? '次にすること、開いておく場所'
: '自由なメモ、次にすること'
}
value={draft.text}
onChange={(event) => onChange({ ...draft, text: event.target.value })}
/>
)}
{draft.kind === '体重' && (
<Input
aria-label="記録元"
placeholder="記録元(体重計など・任意)"
value={draft.source}
onChange={(event) =>
onChange({ ...draft, source: event.target.value })
}
/>
)}
<div className="journal-editor-footer">
<label className="journal-date-label" htmlFor={dateId}>
発生日時(任意)
<Input
id={dateId}
type="datetime-local"
aria-label="発生日時"
value={draft.occurredAt}
onChange={(event) =>
onChange({ ...draft, occurredAt: event.target.value })
}
/>
</label>
<Button type="submit" disabled={!valid}>
{submitLabel}
</Button>
</div>
</form>
)
}
function CorrectionDialog({
entry,
onClose,
onSave,
}: {
entry: Entry
onClose: () => void
onSave: (draft: Draft) => void
}) {
const [draft, setDraft] = useState<Draft>(entry)
return (
<Dialog title="記録を訂正" onClose={onClose} className="sm:max-w-xl">
<p className="text-sm text-muted-foreground">
元の記録を残して、訂正を追加します。
</p>
<EntryEditor
draft={draft}
onChange={setDraft}
onSubmit={() => onSave(draft)}
submitLabel="訂正を追加"
/>
</Dialog>
)
}
function buildSummary(entries: Entry[]) {
if (!entries.length) return '記録を選択してください。'
const dates = entries
.map((entry) => entry.occurredAt)
.filter(Boolean)
.sort()
const period = dates.length
? `${formatTime(dates[0])} 〜 ${formatTime(dates[dates.length - 1])}`
: '不明'
return [
`発生日時の期間: ${period}`,
`発生日時が不明の記録: ${entries.filter((entry) => !entry.occurredAt).length}件`,
'',
...entries.map(
(entry) =>
`[${entry.kind}] ${entry.text}${entry.kind === '体重' ? ' kg' : ''}\n発生日時: ${formatTime(entry.occurredAt)} / 記録日時: ${formatTime(entry.recordedAt)}${entry.kind === '体重' ? ` / 記録元: ${entry.source || '不明'}` : ''}`,
),
'',
'記録のない期間の服薬・体重は不明です。',
].join('\n')
}
+129
View File
@@ -0,0 +1,129 @@
.journal-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.journal-main {
min-width: 0;
overflow: hidden;
}
.journal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 68px;
padding: 16px 28px;
border-bottom: 1px solid var(--border);
}
.journal-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.journal-column {
width: min(100%, 760px);
margin: 0 auto;
padding: 40px 28px;
}
.journal-editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.journal-kind-switch {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.journal-editor textarea {
min-height: 112px;
}
.journal-editor-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
}
.journal-date-label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: var(--muted-foreground);
min-width: 0;
}
.journal-date-label input {
max-width: 100%;
}
.journal-entries {
margin-top: 36px;
}
.journal-empty {
padding: 40px 0;
color: var(--muted-foreground);
font-size: 14px;
text-align: center;
}
.journal-entry {
border-top: 1px solid var(--border);
padding: 20px 0;
}
.journal-entry-heading {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
}
.journal-entry-text {
margin: 12px 0;
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.journal-review-list {
display: flex;
flex-direction: column;
gap: 16px;
max-height: 220px;
overflow: auto;
padding: 4px;
}
.journal-review-row {
display: flex;
align-items: flex-start;
gap: 12px;
overflow-wrap: anywhere;
}
.journal-review-row [data-slot="checkbox"] {
margin-top: 4px;
}
.journal-summary {
min-height: 180px;
max-height: 280px;
overflow-y: auto;
font-size: 13px;
}
@media (max-width: 600px) {
.journal-header {
padding: 14px;
flex-wrap: wrap;
}
.journal-column {
padding: 24px 14px;
}
.journal-editor-footer {
align-items: stretch;
flex-direction: column;
}
.journal-editor-footer button {
align-self: flex-end;
}
.journal-kind-switch {
gap: 0;
}
.journal-kind-switch button {
padding-inline: 8px;
}
}
@@ -1,10 +1,10 @@
.research-post.citation-highlight {
outline: 2px solid var(--accent, #67b7ff);
outline: 2px solid var(--primary);
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 {
outline: 2px solid var(--accent, #67b7ff);
outline: 2px solid var(--primary);
outline-offset: -2px;
}
+26 -123
View File
@@ -1,16 +1,14 @@
.app.app-deck {
grid-template-columns: 340px minmax(0, 1fr);
}
.research-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
border-right: 1px solid var(--border);
background: var(--paper);
background: var(--background);
overflow: hidden;
}
.research-heading {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
@@ -23,13 +21,11 @@
margin: 0;
font-size: 1rem;
}
.research-heading button {
font-size: 0.75rem;
}
.research-context {
margin: 0.5rem 0 0;
color: var(--secondary);
margin: 0 0 0.75rem;
color: var(--muted-foreground);
font-size: 0.75rem;
overflow-wrap: anywhere;
}
.research-history {
display: flex;
@@ -38,20 +34,14 @@
width: 100%;
min-width: 0;
font-size: 0.75rem;
color: var(--secondary);
color: var(--muted-foreground);
}
.research-history span {
flex-shrink: 0;
}
.research-history select {
.research-history > [data-slot="native-select-wrapper"] {
flex: 1;
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 {
padding: 0.5rem 1rem;
@@ -61,45 +51,32 @@
.research-conversation {
flex: 1;
min-height: 80px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem;
}
.research-empty {
color: var(--secondary);
color: var(--muted-foreground);
font-size: 0.85rem;
line-height: 1.8;
}
.research-message {
margin-bottom: 1.25rem;
font-size: 0.875rem;
line-height: 1.65;
overflow-wrap: anywhere;
}
.research-message strong {
font-size: 0.7rem;
color: var(--secondary);
}
.research-message p {
margin: 0.35rem 0 0;
white-space: pre-wrap;
}
.research-message-user {
border-left: 2px solid var(--accent);
padding-left: 0.75rem;
}
.research-message-tool {
color: var(--secondary);
.research-tool-message {
color: var(--muted-foreground);
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.research-start-form {
flex-shrink: 0;
padding: 0.75rem;
border-top: 1px solid var(--border);
}
.research-context {
margin: 0 0 0.75rem;
overflow-wrap: anywhere;
}
.research-start-form > fieldset {
border: 0;
margin: 0;
@@ -111,21 +88,9 @@
.research-start-form label {
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 {
font-size: 0.75rem;
}
.research-account-picker summary {
cursor: pointer;
}
.research-accounts {
display: grid;
gap: 0.5rem;
@@ -141,10 +106,8 @@
align-items: center;
overflow-wrap: anywhere;
}
.research-accounts input {
width: auto;
}
.research-run {
flex-shrink: 0;
border-top: 1px solid var(--border);
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
@@ -159,73 +122,6 @@
overflow-wrap: anywhere;
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 {
min-width: 0;
overflow-wrap: anywhere;
@@ -238,26 +134,33 @@
.research-markdown h5,
.research-markdown h6 {
font-size: 1em;
font-weight: 600;
margin: 1em 0 0.4em;
}
.research-markdown ul,
.research-markdown ol {
padding-left: 1.4rem;
}
.research-markdown ul {
list-style: disc;
}
.research-markdown ol {
list-style: decimal;
}
.research-markdown li + li {
margin-top: 0.25rem;
}
.research-markdown blockquote {
margin: 0.75rem 0;
padding-left: 0.75rem;
border-left: 2px solid var(--control-border);
color: var(--secondary);
border-left: 2px solid var(--border);
color: var(--muted-foreground);
}
.research-markdown pre {
max-width: 100%;
overflow-x: auto;
padding: 0.75rem;
background: var(--canvas);
background: var(--muted);
white-space: pre;
font-size: 0.75rem;
}
@@ -281,7 +184,7 @@
text-align: left;
}
.research-markdown a {
color: var(--accent);
color: var(--primary);
text-decoration: underline;
text-underline-offset: 0.15em;
}
+41 -10
View File
@@ -83,6 +83,7 @@ function show(connections = [connection], ready = true) {
const ui = (
ready = true,
contextDeck = { id: 'manual', title: '手動デッキ', columns: [] },
presentation = 'desktop',
) => (
<QueryClientProvider client={client}>
<ResearchPanel
@@ -91,16 +92,23 @@ function show(connections = [connection], ready = true) {
connections={connections}
onOpenDeck={onOpenDeck}
onNavigateCitation={onNavigateCitation}
/>
>
{(renderPanel) => <div key={presentation}>{renderPanel()}</div>}
</ResearchPanel>
</QueryClientProvider>
)
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'))
show()
const view = show([
connection,
{ ...connection, id: 'second', displayName: 'second' },
])
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('メッセージ'), {
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.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: '送信' }))
await waitFor(() => expect(actions.start).toHaveBeenCalledTimes(2))
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')
fireEvent.click(screen.getByText('接続アカウント · 6件'))
expect(screen.getByLabelText('account6 · Twitter')).toBeDisabled()
fireEvent.click(screen.getByLabelText('account0 · Twitter'))
expect(screen.getByLabelText('account6 · Twitter')).toBeEnabled()
expect(
screen.getByRole('checkbox', { name: 'account6 · Twitter' }),
).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 () => {
@@ -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 () => {
const view = show([])
await waitFor(() => expect(screen.getByLabelText('メッセージ')).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '接続アカウント · 0件' }))
await screen.findByText('接続アカウントを先に追加してください。')
view.rerender(
<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.getByText('中断しました・続きから再開できます')).toBeVisible()
fireEvent.click(screen.getByText('接続アカウント · 1件'))
expect(screen.getByLabelText('調査用アカウント · Twitter')).not.toBeChecked()
expect(screen.getByLabelText('account2 · Twitter')).toBeChecked()
fireEvent.click(screen.getByLabelText('調査用アカウント · Twitter'))
expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).not.toBeChecked()
expect(
screen.getByRole('checkbox', { name: 'account2 · Twitter' }),
).toBeChecked()
fireEvent.click(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
)
act(() =>
MockEventSource.instances[0]?.onmessage?.({
data: JSON.stringify({
@@ -612,7 +641,9 @@ it('opens a saved chat with its context deck and selected accounts, preserving l
run: { message: '更新' },
}),
)
expect(screen.getByLabelText('調査用アカウント · Twitter')).toBeChecked()
expect(
screen.getByRole('checkbox', { name: '調査用アカウント · Twitter' }),
).toBeChecked()
expect(view.onOpenDeck).toHaveBeenCalledOnce()
fireEvent.change(screen.getByLabelText('メッセージ'), {
target: { value: '続きを調べて' },
+226 -128
View File
@@ -1,6 +1,38 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { Deck } from '../decks/model'
import {
@@ -28,6 +60,12 @@ const statusNames: Record<ResearchRun['status'], string> = {
interrupted: '中断しました・続きから再開できます',
}
type ResearchInput = { topic: string; connectionIds: string[] }
type ResearchDraft = {
runId: string | null
topic: string
selected: string[] | null
}
type RenderResearchPanel = (onCitationNavigated?: () => void) => ReactNode
export function ResearchPanel({
ready,
@@ -35,12 +73,14 @@ export function ResearchPanel({
connections,
onOpenDeck,
onNavigateCitation,
children,
}: {
ready: boolean
contextDeck: Deck
connections: Connection[]
onOpenDeck: (deck: Deck, activate?: boolean) => unknown
onNavigateCitation?: (url: string, citations: ResearchCitation[]) => boolean
children?: (renderPanel: RenderResearchPanel) => ReactNode
}) {
const fetchStatus = useServerFn(loadResearchStatus)
const start = useServerFn(startResearch)
@@ -48,16 +88,33 @@ export function ResearchPanel({
const reset = useServerFn(resetResearch)
const select = useServerFn(selectResearch)
const client = useQueryClient()
const historyId = useId()
const status = useQuery({
queryKey: ['research-status'],
queryFn: () => fetchStatus(),
retry: false,
})
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 [error, setError] = useState<string>()
const [reconnecting, setReconnecting] = useState(false)
const [draftResetVersion, setDraftResetVersion] = useState(0)
const previousRunId = useRef<string | null>(null)
const syncedRunId = useRef<string | null>(null)
const attemptedVersion = useRef<string | null>(null)
@@ -79,7 +136,6 @@ export function ResearchPanel({
useEffect(() => {
if (previousRunId.current && previousRunId.current !== run?.id) {
setDraftResetVersion((value) => value + 1)
request.current = null
pendingTurn.current = null
}
@@ -209,7 +265,7 @@ export function ResearchPanel({
)
request.current = null
pendingTurn.current = null
setDraftResetVersion((value) => value + 1)
updateDraft({ topic: '' })
} catch {
setError(
'新しいチャットを開始できませんでした。会話と入力内容は残っています。もう一度お試しください。',
@@ -252,11 +308,13 @@ export function ResearchPanel({
}
}
return (
const renderPanel: RenderResearchPanel = (onCitationNavigated) => (
<aside className="research-panel" aria-label="調査チャット">
<header className="research-heading">
<h2>調査チャット</h2>
<button
<Button
variant="ghost"
size="sm"
type="button"
disabled={busy || !run || isResearchActive(run)}
title={
@@ -267,24 +325,26 @@ export function ResearchPanel({
onClick={() => void newChat()}
>
新しいチャット
</button>
</Button>
{(status.data?.history?.length ?? 0) > 0 && (
<label className="research-history">
<label className="research-history" htmlFor={historyId}>
<span>チャット履歴</span>
<select
<NativeSelect
id={historyId}
size="sm"
value={run?.id ?? ''}
disabled={busy || isResearchActive(run)}
onChange={(event) => void selectChat(event.target.value)}
>
<option value="" disabled>
<NativeSelectOption value="" disabled>
新しいチャット
</option>
</NativeSelectOption>
{status.data?.history?.map((entry) => (
<option key={entry.id} value={entry.id}>
<NativeSelectOption key={entry.id} value={entry.id}>
{entry.title}
</option>
</NativeSelectOption>
))}
</select>
</NativeSelect>
</label>
)}
</header>
@@ -296,9 +356,9 @@ export function ResearchPanel({
{status.isError && (
<p className="deck-error" role="alert">
調査状況を取得できませんでした。
<button type="button" onClick={() => void status.refetch()}>
<Button type="button" onClick={() => void status.refetch()}>
再試行
</button>
</Button>
</p>
)}
{error && (
@@ -315,7 +375,11 @@ export function ResearchPanel({
key={`conversation:${run?.id ?? 'new'}`}
messages={run?.messages ?? []}
citations={run?.citations ?? []}
onNavigateCitation={onNavigateCitation}
onNavigateCitation={(url, citations) => {
const navigated = onNavigateCitation?.(url, citations) ?? false
if (navigated) onCitationNavigated?.()
return navigated
}}
/>
{run && (
<ResearchRunStatus
@@ -327,8 +391,10 @@ export function ResearchPanel({
)}
<ResearchComposer
key={run?.id ?? 'new'}
initialConnectionIds={run?.connectionIds}
resetVersion={draftResetVersion}
topic={draft.topic}
selected={draft.selected}
onTopicChange={(topic) => updateDraft({ topic })}
onSelectionChange={(selected) => updateDraft({ selected })}
connections={connections}
contextTitle={contextDeck.title}
disabled={
@@ -338,6 +404,7 @@ export function ResearchPanel({
/>
</aside>
)
return children ? children(renderPanel) : renderPanel()
}
function ResearchConversation({
@@ -349,66 +416,81 @@ function ResearchConversation({
citations: ResearchCitation[]
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: 'ツール実行' }
return (
<div
className="research-conversation"
role="log"
aria-label="調査の会話"
aria-live="polite"
ref={container}
onScroll={() => {
const element = container.current
if (element)
follow.current =
element.scrollHeight - element.scrollTop - element.clientHeight < 60
}}
>
{messages.length === 0 ? (
<p className="research-empty">
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
</p>
) : (
messages.map((message) => (
<article
className={`research-message research-message-${message.role}`}
key={message.id}
>
<strong>{names[message.role]}</strong>
{message.role === 'assistant' ? (
<ResearchMarkdown
text={message.text}
onNavigateCitation={(url) =>
onNavigateCitation?.(url, citations) ?? false
}
/>
<MessageScrollerProvider autoScroll>
<MessageScroller className="research-conversation">
<MessageScrollerViewport aria-label="調査メッセージ">
<MessageScrollerContent aria-label="調査の会話" className="gap-5 p-4">
{messages.length === 0 ? (
<MessageScrollerItem messageId="empty">
<p className="research-empty">
気になる話題や調べたいことを送ってください。検索を進めながらカラムを追加し、レポートを自宅サーバーに保存します。
</p>
</MessageScrollerItem>
) : (
<p>{message.text}</p>
messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === 'user'}
>
{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' ? (
<ResearchMarkdown
text={message.text}
onNavigateCitation={(url) =>
onNavigateCitation?.(url, citations) ?? false
}
/>
) : (
<Bubble align="end">
<BubbleContent>
<p>{message.text}</p>
</BubbleContent>
</Bubble>
)}
</MessageContent>
</Message>
)}
</MessageScrollerItem>
))
)}
</article>
))
)}
</div>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton aria-label="最新のメッセージへ" />
</MessageScroller>
</MessageScrollerProvider>
)
}
function ResearchComposer({
initialConnectionIds,
resetVersion,
topic,
selected,
onTopicChange,
onSelectionChange,
connections,
contextTitle,
disabled,
onSend,
}: {
initialConnectionIds?: string[]
resetVersion: number
topic: string
selected: string[] | null
onTopicChange: (topic: string) => void
onSelectionChange: (selected: string[]) => void
connections: Connection[]
contextTitle: string
disabled: boolean
@@ -417,13 +499,6 @@ function ResearchComposer({
const available = connections.filter(
(connection) => connection.status === 'connected',
)
const [topic, setTopic] = useState('')
useEffect(() => {
if (resetVersion > 0) setTopic('')
}, [resetVersion])
const [selected, setSelected] = useState<string[] | null>(
initialConnectionIds ?? null,
)
const selectedIds =
selected ?? available.slice(0, 6).map((connection) => connection.id)
const id = useId()
@@ -441,7 +516,7 @@ function ResearchComposer({
topic: topic.trim(),
connectionIds: validSelection,
}).then((sent) => {
if (sent) setTopic('')
if (sent) onTopicChange('')
})
}}
>
@@ -449,56 +524,69 @@ function ResearchComposer({
送信するデッキ: <strong>{contextTitle}</strong>
</p>
<fieldset disabled={disabled}>
<details className="research-account-picker">
<summary>接続アカウント · {validSelection.length}件</summary>
<fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend>
{available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p>
) : (
available.map((connection) => (
<label key={connection.id}>
<input
type="checkbox"
checked={selectedIds.includes(connection.id)}
disabled={
!selectedIds.includes(connection.id) &&
validSelection.length >= 6
}
onChange={(event) =>
setSelected(
event.target.checked
? [...selectedIds, connection.id]
: selectedIds.filter(
(value) => value !== connection.id,
),
)
}
/>
{connection.displayName} ·{' '}
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
</label>
))
)}
</fieldset>
</details>
<Collapsible className="research-account-picker" disabled={disabled}>
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
接続アカウント · {validSelection.length}件
</CollapsibleTrigger>
<CollapsibleContent>
<fieldset className="research-accounts">
<legend>調査に使う接続アカウント(最大6件)</legend>
{available.length === 0 ? (
<p>接続アカウントを先に追加してください。</p>
) : (
available.map((connection) => (
<label key={connection.id} htmlFor={`${id}-${connection.id}`}>
<Checkbox
id={`${id}-${connection.id}`}
checked={selectedIds.includes(connection.id)}
disabled={
disabled ||
(!selectedIds.includes(connection.id) &&
validSelection.length >= 6)
}
onCheckedChange={(checked) =>
onSelectionChange(
checked
? [...selectedIds, connection.id]
: selectedIds.filter(
(value) => value !== connection.id,
),
)
}
/>
{connection.displayName} ·{' '}
{connection.platform === 'twitter' ? 'Twitter' : 'Mastodon'}
</label>
))
)}
</fieldset>
</CollapsibleContent>
</Collapsible>
<label htmlFor={id}>メッセージ</label>
<textarea
id={id}
value={topic}
onChange={(event) => setTopic(event.target.value)}
maxLength={1000}
rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
required
/>
<button
type="submit"
className="deck-primary"
disabled={disabled || !topic.trim() || validSelection.length === 0}
>
送信
</button>
<InputGroup>
<InputGroupTextarea
id={id}
value={topic}
onChange={(event) => onTopicChange(event.target.value)}
maxLength={1000}
rows={3}
placeholder="例:WebMCPの反応を比較して。その後、このデッキを絞り込んで。"
required
disabled={disabled}
/>
<InputGroupAddon align="block-end" className="justify-end">
<InputGroupButton
type="submit"
variant="default"
size="sm"
disabled={
disabled || !topic.trim() || validSelection.length === 0
}
>
送信
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</fieldset>
</form>
)
@@ -518,7 +606,9 @@ function ResearchRunStatus({
const deck = run.deck ?? run.contextDeck
return (
<section className="research-run" aria-label="調査状況">
<p role="status">{statusNames[run.status]}</p>
<Marker role="status">
<MarkerContent>{statusNames[run.status]}</MarkerContent>
</Marker>
{run.error && (
<p className="deck-error" role="alert">
{run.error}
@@ -533,19 +623,27 @@ function ResearchRunStatus({
)}
<div className="deck-actions">
{isResearchActive(run) && (
<button type="button" disabled={busy} onClick={onCancel}>
<Button
variant="outline"
size="sm"
type="button"
disabled={busy}
onClick={onCancel}
>
調査を中止
</button>
</Button>
)}
{deck && (
<button
<Button
variant="outline"
size="sm"
type="button"
onClick={() => {
onOpenDeck(deck)
}}
>
一時デッキで開く
</button>
</Button>
)}
</div>
</section>
@@ -0,0 +1,283 @@
import { CalendarPlus, Check, ChevronDown, Send } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '#/components/ui/dialog'
import { NativeSelect, NativeSelectOption } from '#/components/ui/native-select'
import { Textarea } from '#/components/ui/textarea'
import type { Conversation } from './mock-conversations'
export function ConversationDetail({
conversation,
onResolve,
}: {
conversation: Conversation
onResolve: (
result: 'sent' | 'waiting' | 'later' | 'dismissed',
note?: string,
) => void
}) {
const [draft, setDraft] = useState(conversation.drafts[0]?.text ?? '')
const [tone, setTone] = useState(conversation.drafts[0]?.label ?? '')
const [review, setReview] = useState<'send' | 'schedule' | null>(null)
const [slot, setSlot] = useState('')
const [scheduled, setScheduled] = useState('')
return (
<article
className="mx-auto w-full max-w-3xl px-5 py-8 sm:px-10 sm:py-10"
aria-label={`${conversation.name}との会話`}
>
<header className="mb-8 space-y-2">
<p className="text-xs text-muted-foreground">
{conversation.channels.join(' / ')} · {conversation.role}
</p>
<h1 className="text-2xl font-medium tracking-tight">
{conversation.name}
</h1>
<p className="text-sm text-muted-foreground">{conversation.subject}</p>
</header>
<section className="mb-8 space-y-4" aria-label="会話の要点">
<p className="text-sm leading-7">{conversation.summary}</p>
<div className="border-l-2 border-border pl-4">
<p className="text-sm leading-6">{conversation.nextStep}</p>
{conversation.deadline && (
<p className="mt-1 text-xs text-muted-foreground">
相手の指定期限 · {conversation.deadline}
</p>
)}
</div>
</section>
<details className="group mb-8 border-y border-border py-3">
<summary className="flex cursor-pointer list-none items-center justify-between text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
元のやりとり{' '}
<ChevronDown className="size-4 text-muted-foreground transition-transform group-open:rotate-180" />
</summary>
<div className="space-y-5 pt-5">
{conversation.messages.map((message) => (
<div
key={message.id}
id={`source-${message.id}`}
className="scroll-mt-5 rounded-lg bg-muted/40 p-4 focus:outline-none"
tabIndex={-1}
>
<p className="mb-2 text-xs text-muted-foreground">
{message.direction === 'in' ? conversation.name : '自分'} ·{' '}
{message.channel} · {message.time}
</p>
<p className="whitespace-pre-wrap text-sm leading-7">
{message.text}
</p>
</div>
))}
</div>
</details>
<section aria-label="返信を作る" className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<label
htmlFor={`support-reply-${conversation.id}`}
className="text-sm font-medium"
>
返信
</label>
<span className="text-xs text-muted-foreground">
{conversation.replyChannel} → {conversation.name}
</span>
</div>
<div className="flex flex-wrap gap-1">
{conversation.drafts.map((option) => (
<Button
key={option.label}
variant={tone === option.label ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={tone === option.label}
onClick={() => {
setDraft(option.text)
setTone(option.label)
}}
>
{option.label}
</Button>
))}
</div>
<Textarea
id={`support-reply-${conversation.id}`}
value={draft}
className="min-h-36 resize-y text-sm leading-7"
onChange={(event) => {
setDraft(event.target.value)
setTone('')
}}
/>
{conversation.scheduleOptions.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label="日程候補"
value={slot}
onChange={(event) => setSlot(event.target.value)}
>
<NativeSelectOption value="">候補日時を選ぶ</NativeSelectOption>
{conversation.scheduleOptions.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
</NativeSelectOption>
))}
</NativeSelect>
<Button
size="sm"
variant="ghost"
disabled={!slot}
onClick={() => {
setDraft(
(current) => `${current}\n\n${slot}はいかがでしょうか。`,
)
setTone('')
}}
>
返信に入れる
</Button>
<Button
size="icon-sm"
variant="ghost"
disabled={!slot}
aria-label="予定を仮置きする"
onClick={() => setReview('schedule')}
>
<CalendarPlus />
</Button>
</div>
)}
{scheduled && (
<p
role="status"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<Check className="size-3" />
{scheduled} をこの画面に仮置きしました
</p>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<Button disabled={!draft.trim()} onClick={() => setReview('send')}>
<Send />
送信内容を確認
</Button>
<div className="flex flex-wrap gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('later')}
>
今は置いておく
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('waiting')}
>
相手待ち
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onResolve('dismissed')}
>
返信せず閉じる
</Button>
</div>
</div>
</section>
<details className="group mt-10 border-t border-border pt-4">
<summary className="flex cursor-pointer list-none items-center justify-between text-xs text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
人物メモ{' '}
<ChevronDown className="size-3 transition-transform group-open:rotate-180" />
</summary>
<ul className="mt-4 space-y-3 text-sm leading-6">
{conversation.facts.map((fact) => (
<li
key={fact.sourceId}
className="flex items-start justify-between gap-3"
>
<span>{fact.text}</span>
<a
className="shrink-0 text-xs text-muted-foreground underline underline-offset-4"
href={`#source-${fact.sourceId}`}
onClick={() => {
const source = document.getElementById(
`source-${fact.sourceId}`,
)
const details = source?.closest('details')
if (details) details.open = true
source?.focus()
}}
>
出典
</a>
</li>
))}
</ul>
</details>
<Dialog
open={review !== null}
onOpenChange={(open) => {
if (!open) setReview(null)
}}
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{review === 'schedule' ? '予定の確認' : '送信内容の確認'}
</DialogTitle>
<DialogDescription>
{review === 'schedule'
? '外部カレンダーには登録せず、この画面だけに仮置きします。'
: '外部には送信されません。完了後の表示を試せます。'}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm font-medium">
{conversation.name}
{review === 'send' && (
<span className="ml-2 font-normal text-muted-foreground">
{conversation.replyChannel}
</span>
)}
</p>
<p className="max-h-[45dvh] overflow-y-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-4 text-sm leading-7">
{review === 'schedule' ? slot : draft}
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setReview(null)}>
戻る
</Button>
<Button
onClick={() => {
if (review === 'schedule') setScheduled(slot)
else
onResolve(
'sent',
`${conversation.replyChannel} → ${conversation.name}(送信プレビュー)\n${draft}`,
)
setReview(null)
}}
>
{review === 'schedule' ? '仮置きを試す' : '送信を試す'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</article>
)
}
+186
View File
@@ -0,0 +1,186 @@
type Channel = 'Slack' | 'LINE' | 'Gmail'
export type Conversation = {
id: string
name: string
role: string
subject: string
channels: Channel[]
replyChannel: Channel
updatedAt: string
deadline?: string
summary: string
nextStep: string
messages: {
id: string
channel: Channel
text: string
time: string
direction: 'in' | 'out'
}[]
facts: { text: string; sourceId: string }[]
drafts: { label: string; text: string }[]
scheduleOptions: string[]
}
// All contacts, conversations and proposed appointments are fictional UI samples.
export const conversations: Conversation[] = [
{
id: 'design-review',
name: '青木 遥',
role: 'プロダクトデザイナー',
subject: '来週のレビュー、日程を決める',
channels: ['Slack', 'Gmail'],
replyChannel: 'Slack',
updatedAt: '10:42',
deadline: '9月29日 17:00',
summary:
'新しい画面のレビュー依頼。資料はメールで届いていて、Slackで日程の返事を待っている。30分あればよい。',
nextStep: '資料を確認して、30分の候補日時を返す。',
messages: [
{
id: 'aoki-1',
channel: 'Gmail',
time: '9月27日 16:20',
direction: 'in',
text: 'レビュー用の画面案を共有します。特に初回の導線について意見を聞きたいです。確認のお時間は30分ほどを想定しています。',
},
{
id: 'aoki-2',
channel: 'Slack',
time: '9月28日 10:42',
direction: 'in',
text: '昨日メールしたレビューの件、来週どこかでお時間いただけますか? 9月29日17時までに候補をいただけると助かります。午後だとうれしいです。',
},
],
facts: [
{ text: 'レビューは30分を想定している。', sourceId: 'aoki-1' },
{ text: '今回の日程は午後を希望している。', sourceId: 'aoki-2' },
],
drafts: [
{
label: '短く',
text: '資料ありがとうございます。確認します! 来週の午後で、30分ほどお話しできればと思います。',
},
{
label: '丁寧に',
text: '資料をご共有いただきありがとうございます。初回の導線を中心に確認いたします。来週の午後に30分ほどお時間をいただけますでしょうか。',
},
{
label: 'まず受け取る',
text: 'ご連絡ありがとうございます。資料、受け取りました。日程を確認して9月29日17時までにお返事します。',
},
],
scheduleOptions: ['10月5日(月)14:00–14:30', '10月6日(火)15:00–15:30'],
},
{
id: 'weekend-coffee',
name: '森 直人',
role: '友人',
subject: '久しぶりに、お茶でも',
channels: ['LINE'],
replyChannel: 'LINE',
updatedAt: '昨日',
summary:
'近くに来るのでお茶をしようという誘い。急ぎの返事は求められていない。',
nextStep: '会いたければ都合を返す。今決めなくてもよい。',
messages: [
{
id: 'mori-1',
channel: 'LINE',
time: '9月27日 19:15',
direction: 'in',
text: '来週そっちの方に行くかも。時間合ったらお茶しない? 土曜の午後なら空いてる。急ぎじゃないので、予定わかったら!',
},
],
facts: [{ text: '今回は土曜の午後が空いている。', sourceId: 'mori-1' }],
drafts: [
{
label: '短く',
text: 'いいね、久しぶりに会いたい! 土曜の午後で予定見てみるね。',
},
{
label: '丁寧に',
text: '誘ってくれてありがとう! ぜひお茶したいです。土曜の午後、都合を確認して連絡するね。',
},
{
label: 'まず受け取る',
text: 'ありがとう! まだ予定が見えないので、わかったら連絡するね。',
},
],
scheduleOptions: ['10月3日(土)14:00–15:00', '10月3日(土)16:00–17:00'],
},
{
id: 'estimate-check',
name: '藤井 理沙',
role: '制作パートナー',
subject: '見積もりの前提を確認する',
channels: ['Gmail'],
replyChannel: 'Gmail',
updatedAt: '9:10',
deadline: '9月30日 12:00',
summary:
'見積もりを進めるため、対象画面が3画面でよいか確認が届いている。回答期限は9月30日の正午。',
nextStep: '対象画面数を確認して返答する。未確認のまま確約しない。',
messages: [
{
id: 'fujii-1',
channel: 'Gmail',
time: '9月28日 9:10',
direction: 'in',
text: '概算の作成にあたり、対象は一覧・詳細・設定の3画面という理解でよろしいでしょうか。9月30日正午までにご確認いただければ、同日中にお見積もりをお送りします。',
},
],
facts: [
{ text: '対象画面数の確認後に概算を作成する予定。', sourceId: 'fujii-1' },
],
drafts: [
{
label: '短く',
text: 'ありがとうございます。対象の3画面について確認し、9月30日正午までにお返事します。',
},
{
label: '丁寧に',
text: 'ご確認ありがとうございます。一覧・詳細・設定の3画面で相違ないか確認のうえ、9月30日正午までに回答いたします。',
},
{
label: 'まず受け取る',
text: 'ご連絡を受け取りました。対象範囲を確認して、改めてご連絡します。',
},
],
scheduleOptions: [],
},
{
id: 'reading-note',
name: '田辺 悠',
role: '勉強会の仲間',
subject: '前に話していた記事',
channels: ['Slack'],
replyChannel: 'Slack',
updatedAt: '金曜日',
summary:
'前の勉強会で話題になった記事を共有してくれた。質問や返答の依頼はない。',
nextStep: '読む時間がなければ、返信せず閉じてよい。',
messages: [
{
id: 'tanabe-1',
channel: 'Slack',
time: '9月25日 18:30',
direction: 'in',
text: '前に話していた、道具を増やさず仕事を進める話の記事を見つけたので共有! 時間あるときにどうぞ。返信不要です。',
},
],
facts: [
{ text: 'この共有への返信は不要と明記している。', sourceId: 'tanabe-1' },
],
drafts: [
{ label: '短く', text: '共有ありがとう! あとで読んでみるね。' },
{
label: '丁寧に',
text: '覚えていてくれてありがとう。ちょうど気になっていた話なので、時間を取って読んでみます。',
},
{ label: 'まず受け取る', text: 'ありがとう、受け取りました!' },
],
scheduleOptions: [],
},
]
+200
View File
@@ -0,0 +1,200 @@
import { ArrowLeft, Search } from 'lucide-react'
import { useState } from 'react'
import { Button } from '#/components/ui/button'
import { Input } from '#/components/ui/input'
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarInset,
SidebarProvider,
} from '#/components/ui/sidebar'
import { TooltipProvider } from '#/components/ui/tooltip'
import { WorkspaceNavigation } from '#/components/workspace-navigation'
import { ConversationDetail } from './conversation-detail'
import { conversations } from './mock-conversations'
import './support.css'
type Status = 'now' | 'later' | 'waiting' | 'sent' | 'dismissed'
const views = [
{ id: 'now', label: '今' },
{ id: 'later', label: 'あとで' },
{ id: 'waiting', label: '相手待ち' },
{ id: 'done', label: '履歴' },
{ id: 'all', label: 'すべて' },
] as const
export function SupportPage() {
const [view, setView] = useState<string>('now')
const [query, setQuery] = useState('')
const [statuses, setStatuses] = useState<Record<string, Status>>({})
const [selectedId, setSelectedId] = useState<string>()
const [reading, setReading] = useState(false)
const [outcomes, setOutcomes] = useState<Record<string, string>>({})
const [notice, setNotice] = useState('')
const matches = conversations.filter((item) => {
const status = statuses[item.id] ?? 'now'
return (
(view === 'all'
? true
: view === 'done'
? status === 'sent' || status === 'dismissed'
: status === view) && `${item.name} ${item.subject}`.includes(query)
)
})
const visible = view === 'now' ? matches.slice(0, 3) : matches
const selected = visible.find((item) => item.id === selectedId) ?? visible[0]
function resolve(id: string, status: Exclude<Status, 'now'>, note?: string) {
setStatuses((current) => ({ ...current, [id]: status }))
if (note) setOutcomes((current) => ({ ...current, [id]: note }))
setNotice(
status === 'sent'
? '返信の操作を記録しました'
: status === 'later'
? 'あとでに移しました'
: status === 'waiting'
? '相手待ちに移しました'
: '対応不要にしました',
)
setReading(false)
}
return (
<TooltipProvider>
<SidebarProvider className="support-workspace" data-reading={reading}>
<Sidebar collapsible="none" className="support-sidebar">
<WorkspaceNavigation />
<Sidebar collapsible="none" className="support-list">
<SidebarHeader className="border-b p-5 gap-4">
<h1 className="text-base font-semibold">連絡</h1>
<div className="flex gap-1 flex-wrap">
{views.map((tab) => (
<Button
key={tab.id}
size="sm"
variant={view === tab.id ? 'secondary' : 'ghost'}
aria-pressed={view === tab.id}
onClick={() => {
setView(tab.id)
setSelectedId(undefined)
setReading(false)
setNotice('')
}}
>
{tab.label}
</Button>
))}
</div>
<div className="relative">
<Search className="absolute left-3 top-2 size-4 text-muted-foreground" />
<Input
aria-label="連絡を検索"
placeholder="検索"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
{visible.map((item) => (
<button
type="button"
className="support-row"
aria-current={selected?.id === item.id ? 'true' : undefined}
key={item.id}
onClick={() => {
setSelectedId(item.id)
setReading(true)
}}
>
<span className="text-xs text-muted-foreground">
{item.name}
</span>
<span className="font-medium text-sm">{item.subject}</span>
</button>
))}
{!visible.length && (
<p className="p-5 text-sm text-muted-foreground">
{query ? '見つかりませんでした' : 'いま扱う連絡はありません'}
</p>
)}
{view === 'now' && matches.length > 3 && (
<p className="p-5 text-xs text-muted-foreground">
ほか {matches.length - 3} 件
</p>
)}
</SidebarContent>
</Sidebar>
</Sidebar>
<SidebarInset className="support-main">
<header className="support-toolbar">
<Button
variant="ghost"
size="icon"
className="support-back"
aria-label="連絡一覧に戻る"
onClick={() => setReading(false)}
>
<ArrowLeft />
</Button>
<span className="text-sm">{selected?.name}</span>
<span
role="status"
className="ml-auto text-xs text-muted-foreground"
>
{notice}
</span>
</header>
<section
className="support-detail"
aria-label="連絡の詳細"
// biome-ignore lint/a11y/noNoninteractiveTabindex: Independent region needs keyboard scrolling.
tabIndex={0}
>
{conversations.map((conversation) => (
<div
key={conversation.id}
hidden={selected?.id !== conversation.id}
>
<ConversationDetail
conversation={conversation}
onResolve={(status, note) =>
resolve(conversation.id, status, note)
}
/>
</div>
))}
{selected ? (
<>
{outcomes[selected.id] && (
<div className="support-outcome">
<h2 className="font-medium text-sm">活動の記録</h2>
<p className="whitespace-pre-wrap mt-2 text-sm">
{outcomes[selected.id]}
</p>
</div>
)}
{view !== 'now' && (
<Button
variant="outline"
className="m-6"
onClick={() => {
setStatuses((current) => ({
...current,
[selected.id]: 'now',
}))
setView('now')
}}
>
今に戻す
</Button>
)}
</>
) : (
<div className="support-empty">連絡を選択</div>
)}
</section>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
+89
View File
@@ -0,0 +1,89 @@
/* Hallmark · sidebar workbench · neutral · P5 H5 E4 S5 R5 V4 */
.support-workspace {
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.support-sidebar {
width: 390px;
flex-shrink: 0;
flex-direction: row;
border-right: 1px solid var(--border);
}
.support-list {
flex: 1;
min-width: 0;
width: auto;
}
.support-row {
display: flex;
flex-direction: column;
gap: 10px;
padding: 22px 20px;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
line-height: 1.6;
}
.support-row:hover {
background: var(--sidebar-accent);
}
.support-row[aria-current="true"] {
background: var(--muted);
box-shadow: inset 2px 0 var(--primary);
}
.support-main {
min-width: 0;
height: 100%;
overflow: hidden;
}
.support-toolbar {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
}
.support-detail {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-wrap: anywhere;
}
.support-detail > * {
max-width: 800px;
margin-inline: auto;
}
.support-outcome {
padding: 24px;
border-top: 1px solid var(--border);
}
.support-empty {
padding: 64px 24px;
color: var(--muted-foreground);
text-align: center;
}
.support-back {
display: none;
}
@media (max-width: 767px) {
.support-sidebar {
width: 100%;
}
.support-main {
display: none;
}
.support-back {
display: inline-flex;
}
.support-workspace[data-reading="true"] .support-sidebar {
display: none;
}
.support-workspace[data-reading="true"] .support-main {
display: flex;
}
.support-toolbar {
padding: 12px;
}
}
+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
}
+73 -2
View File
@@ -9,11 +9,29 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SupportRouteImport } from './routes/support'
import { Route as JournalRouteImport } from './routes/journal'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as DeckRouteImport } from './routes/deck'
import { Route as IndexRouteImport } from './routes/index'
import { Route as OauthMastodonCallbackRouteImport } from './routes/oauth/mastodon/callback'
import { Route as ApiResearchEventsRouteImport } from './routes/api/research/events'
const SupportRoute = SupportRouteImport.update({
id: '/support',
path: '/support',
getParentRoute: () => rootRouteImport,
} as any)
const JournalRoute = JournalRouteImport.update({
id: '/journal',
path: '/journal',
getParentRoute: () => rootRouteImport,
} as any)
const InboxRoute = InboxRouteImport.update({
id: '/inbox',
path: '/inbox',
getParentRoute: () => rootRouteImport,
} as any)
const DeckRoute = DeckRouteImport.update({
id: '/deck',
path: '/deck',
@@ -38,12 +56,18 @@ const ApiResearchEventsRoute = ApiResearchEventsRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/support': typeof SupportRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/support': typeof SupportRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
@@ -51,18 +75,38 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/deck': typeof DeckRoute
'/inbox': typeof InboxRoute
'/journal': typeof JournalRoute
'/support': typeof SupportRoute
'/api/research/events': typeof ApiResearchEventsRoute
'/oauth/mastodon/callback': typeof OauthMastodonCallbackRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/deck' | '/api/research/events' | '/oauth/mastodon/callback'
fullPaths:
| '/'
| '/deck'
| '/inbox'
| '/journal'
| '/support'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/deck' | '/api/research/events' | '/oauth/mastodon/callback'
to:
| '/'
| '/deck'
| '/inbox'
| '/journal'
| '/support'
| '/api/research/events'
| '/oauth/mastodon/callback'
id:
| '__root__'
| '/'
| '/deck'
| '/inbox'
| '/journal'
| '/support'
| '/api/research/events'
| '/oauth/mastodon/callback'
fileRoutesById: FileRoutesById
@@ -70,12 +114,36 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DeckRoute: typeof DeckRoute
InboxRoute: typeof InboxRoute
JournalRoute: typeof JournalRoute
SupportRoute: typeof SupportRoute
ApiResearchEventsRoute: typeof ApiResearchEventsRoute
OauthMastodonCallbackRoute: typeof OauthMastodonCallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/support': {
id: '/support'
path: '/support'
fullPath: '/support'
preLoaderRoute: typeof SupportRouteImport
parentRoute: typeof rootRouteImport
}
'/journal': {
id: '/journal'
path: '/journal'
fullPath: '/journal'
preLoaderRoute: typeof JournalRouteImport
parentRoute: typeof rootRouteImport
}
'/inbox': {
id: '/inbox'
path: '/inbox'
fullPath: '/inbox'
preLoaderRoute: typeof InboxRouteImport
parentRoute: typeof rootRouteImport
}
'/deck': {
id: '/deck'
path: '/deck'
@@ -110,6 +178,9 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DeckRoute: DeckRoute,
InboxRoute: InboxRoute,
JournalRoute: JournalRoute,
SupportRoute: SupportRoute,
ApiResearchEventsRoute: ApiResearchEventsRoute,
OauthMastodonCallbackRoute: OauthMastodonCallbackRoute,
}
+1 -1
View File
@@ -38,7 +38,7 @@ function RootContent() {
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<html lang="ja" className="dark">
<head>
<HeadContent />
</head>
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { InboxPage } from '#/features/inbox/inbox-page'
export const Route = createFileRoute('/inbox')({ component: InboxPage })
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { JournalPage } from '#/features/journal/journal-page'
export const Route = createFileRoute('/journal')({ component: JournalPage })
+3
View File
@@ -0,0 +1,3 @@
import { createFileRoute } from '@tanstack/react-router'
import { SupportPage } from '#/features/support/support-page'
export const Route = createFileRoute('/support')({ component: SupportPage })
+20 -63
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";
:root {
@@ -6,10 +7,7 @@
--surface: var(--color-paper-2);
--paper: var(--color-paper);
--ink: var(--color-ink);
--secondary: var(--color-muted);
--accent: var(--color-accent);
--accent-hover: var(--color-accent-hover);
--border: var(--color-rule);
--control-border: var(--color-control-rule);
--error: var(--color-error);
color: var(--ink);
@@ -37,7 +35,7 @@ select {
font: inherit;
}
a {
color: var(--accent);
color: var(--primary);
text-underline-offset: 0.18em;
}
a:active {
@@ -47,47 +45,6 @@ a:active {
outline: var(--rule-active) solid var(--color-focus);
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 {
margin: 0;
min-width: 0;
@@ -99,24 +56,24 @@ h1 {
}
.handle {
display: block;
color: var(--secondary);
color: var(--muted-foreground);
font-size: var(--text-xs);
}
@media (max-width: 767px) {
.app-deck {
grid-template-columns: minmax(0, 1fr);
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 {
display: none;
.workspace .reader {
overflow: hidden;
}
.sidebar-note {
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 */
:root {
--color-canvas: oklch(19% 0.021 250);
--color-paper: oklch(24% 0.025 250);
--color-paper-2: oklch(28% 0.027 250);
--color-paper-3: oklch(33% 0.031 250);
--color-ink: oklch(95% 0.005 250);
--color-ink-2: oklch(85% 0.015 250);
--color-muted: oklch(72% 0.025 250);
--color-accent: oklch(72% 0.15 240);
--color-accent-hover: oklch(79% 0.13 240);
--color-accent-ink: oklch(18% 0.03 250);
--color-rule: oklch(34% 0.023 250);
--color-control-rule: oklch(47% 0.029 250);
--color-focus: oklch(79% 0.13 240);
--color-error: oklch(78% 0.13 20);
--color-canvas: var(--background);
--color-paper: var(--background);
--color-paper-2: var(--card);
--color-paper-3: var(--muted);
--color-ink: var(--foreground);
--color-ink-2: var(--foreground);
--color-muted: var(--muted-foreground);
--color-accent: var(--primary);
--color-accent-hover: var(--foreground);
--color-accent-ink: var(--primary-foreground);
--color-rule: var(--border);
--color-control-rule: var(--input);
--color-focus: var(--ring);
--color-error: var(--destructive);
--color-success: oklch(78% 0.13 155);
--color-shadow: oklch(10% 0.015 250 / 45%);
--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 viteReact from '@vitejs/plugin-react'
import { nitro } from 'nitro/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [nitro(), tanstackStart(), viteReact()],
plugins: [tailwindcss(), nitro(), tanstackStart(), viteReact()],
})