feat(web/app): add the Core-3 system layers
Introduce the presentation layers the workspace is built from: base controls (ui), composition pieces (components) and page regions (blocks). System layers depend on nothing above them and take all content through props.
ZhenYi committed
Added view/app/src/blocks/app-shell.tsx view/app/src/blocks/app-shell.tsx +197 −0 View view/app/src/blocks/app-shell.tsx
@@ -0,0 +1,197 @@
1import { useT } from '@app/i18n'
2import { ChevronDown, Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
3import { type ReactNode, useEffect, useRef, useState } from 'react'
4
5/** Shared, responsive workspace frame. Content panes own their scroll position. */
6export interface AppShellProps {
7 rail?: ReactNode
8 sideHeader?: ReactNode
9 /** Pinned rows above the "工作区" caption (e.g. the personal inbox entry). */
10 navPinned?: ReactNode
11 nav?: ReactNode
12 sideFooter?: ReactNode
13 /** Directory-style column (explore): the rail stays, the space sidebar goes. */
14 railOnly?: boolean
15 children: ReactNode
16}
17
18export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, railOnly = false, children }: AppShellProps) {
19 const { t } = useT()
20 const [navigationOpen, setNavigationOpen] = useState(false)
21 const [collapsed, setCollapsed] = useState(false)
22 const navigation = useRef<HTMLDivElement>(null)
23 const menu = useRef<HTMLButtonElement>(null)
24
25 useEffect(() => {
26 if (!navigationOpen) return
27 const panel = navigation.current
28 const previous = document.activeElement as HTMLElement | null
29 panel?.querySelector<HTMLElement>('button, a[href]')?.focus()
30 function onKey(event: KeyboardEvent) {
31 if (event.key === 'Escape') setNavigationOpen(false)
32 if (event.key !== 'Tab' || !panel) return
33 const items = [
34 ...panel.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled])'),
35 ].filter((item) => item.getClientRects().length)
36 const first = items[0]
37 const last = items[items.length - 1]
38 if (event.shiftKey && document.activeElement === first) {
39 event.preventDefault()
40 last?.focus()
41 } else if (!event.shiftKey && document.activeElement === last) {
42 event.preventDefault()
43 first?.focus()
44 }
45 }
46 window.addEventListener('keydown', onKey)
47 return () => {
48 window.removeEventListener('keydown', onKey)
49 if (previous?.isConnected) previous.focus()
50 }
51 }, [navigationOpen])
52
53 useEffect(() => {
54 const viewport = window.matchMedia('(min-width: 761px)')
55 const closeDrawer = () => {
56 if (viewport.matches) setNavigationOpen(false)
57 }
58 viewport.addEventListener('change', closeDrawer)
59 return () => viewport.removeEventListener('change', closeDrawer)
60 }, [])
61
62 return (
63 <div
64 className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''} ${railOnly ? 'rail-only' : ''}`}
65 >
66 <button
67 type="button"
68 className="workspace-skip"
69 onClick={() => document.getElementById('workspace-main')?.focus()}
70 >
71 {t('ui.skipToContent')}
72 </button>
73 <div className="workspace-mobile-bar">
74 <button
75 ref={menu}
76 type="button"
77 className="workspace-icon-button"
78 aria-label={t('ui.openNavigation')}
79 aria-expanded={navigationOpen}
80 aria-controls="workspace-navigation"
81 onClick={() => setNavigationOpen(true)}
82 >
83 <Menu size={20} />
84 </button>
85 <span className="font-semibold">
86 GitData<span className="ml-2 text-xs font-normal text-shell-ink-muted">{t('ui.workspaceLabel')}</span>
87 </span>
88 </div>
89 {navigationOpen ? (
90 <button
91 type="button"
92 className="workspace-scrim"
93 aria-label={t('ui.closeNavigation')}
94 tabIndex={-1}
95 onClick={() => setNavigationOpen(false)}
96 />
97 ) : null}
98 <div
99 id="workspace-navigation"
100 ref={navigation}
101 className="workspace-navigation"
102 role={navigationOpen ? 'dialog' : undefined}
103 aria-modal={navigationOpen || undefined}
104 aria-label={t('ui.navigation')}
105 onClick={(event) => {
106 if ((event.target as HTMLElement).closest('a[href]')) setNavigationOpen(false)
107 }}
108 >
109 <div className="workspace-navigation-columns">
110 <aside className="workspace-rail" aria-label={t('ui.spaceList')}>
111 {rail}
112 </aside>
113 <aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
114 <div className="workspace-sidebar-header">
115 {sideHeader ?? (
116 <>
117 <span className="min-w-0 flex-1 truncate font-semibold">{t('ui.personalSpace')}</span>
118 <ChevronDown size={16} className="text-shell-ink-muted" />
119 </>
120 )}
121 <button
122 type="button"
123 className="workspace-drawer-close workspace-icon-button"
124 aria-label={t('ui.collapseNavigation')}
125 onClick={() => setNavigationOpen(false)}
126 >
127 <X size={18} />
128 </button>
129 </div>
130 {navPinned ? (
131 <div className="workspace-nav-pinned" aria-label={t('ui.shortcuts')}>
132 {navPinned}
133 </div>
134 ) : null}
135 <div className="workspace-nav-caption">
136 <span>{t('ui.workspace')}</span>
137 <button
138 type="button"
139 className="workspace-collapse workspace-icon-button"
140 aria-label={t('ui.collapseSidebar')}
141 title={t('ui.collapseSidebar')}
142 onClick={() => setCollapsed(true)}
143 >
144 <PanelLeftClose size={16} />
145 </button>
146 </div>
147 <nav className="workspace-nav" aria-label={t('ui.navScope')}>
148 {nav}
149 </nav>
150 </aside>
151 </div>
152 {sideFooter ? <div className="workspace-navigation-footer">{sideFooter}</div> : null}
153 </div>
154 <main id="workspace-main" tabIndex={-1} className="workspace-main" inert={navigationOpen || undefined}>
155 {/* Always mounted: `visibility` hides it when the sidebar is open, which keeps
156 it out of the tab order while still letting it fade in and out. */}
157 <button
158 type="button"
159 className="workspace-expand workspace-icon-button"
160 aria-label={t('ui.expandSidebar')}
161 title={t('ui.expandSidebar')}
162 onClick={() => setCollapsed(false)}
163 >
164 <PanelLeftOpen size={18} />
165 </button>
166 {children}
167 </main>
168 </div>
169 )
170}
171
172export interface ShellTopBarProps {
173 icon?: ReactNode
174 title: ReactNode
175 /** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
176 titleMenu?: ReactNode
177 topic?: ReactNode
178 /** Page-level view switch, rendered beside the title. */
179 tabs?: ReactNode
180 actions?: ReactNode
181}
182
183export function ShellTopBar({ icon, title, titleMenu, topic, tabs, actions }: ShellTopBarProps) {
184 return (
185 <header className="shell-top-bar">
186 {icon ? <span className="shell-top-icon">{icon}</span> : null}
187 <div className="min-w-0 flex-1">
188 <div className="flex min-w-0 items-center gap-3">
189 {titleMenu ?? <h1 className="truncate text-base font-semibold">{title}</h1>}
190 {tabs ? <div className="flex shrink-0 items-center">{tabs}</div> : null}
191 </div>
192 {topic ? <p className="mt-0.5 truncate text-xs text-shell-ink-muted">{topic}</p> : null}
193 </div>
194 {actions ? <div className="shell-top-actions">{actions}</div> : null}
195 </header>
196 )
197}
Added view/app/src/blocks/index.ts view/app/src/blocks/index.ts +1 −0 View view/app/src/blocks/index.ts
@@ -0,0 +1 @@
1export * from './app-shell'
Added view/app/src/components/brand/index.ts view/app/src/components/brand/index.ts +1 −0 View view/app/src/components/brand/index.ts
@@ -0,0 +1 @@
1export { KernelMark, type KernelMarkProps } from './kernel-mark'
Added view/app/src/components/brand/kernel-mark.tsx view/app/src/components/brand/kernel-mark.tsx +25 −0 View view/app/src/components/brand/kernel-mark.tsx
@@ -0,0 +1,25 @@
1export interface KernelMarkProps {
2 size?: number
3 radius?: number
4}
5
6/**
7 * The GitDataAI brand mark, served from `/favicon.png` so the header, footer,
8 * auth screens and the browser tab always show the same artwork.
9 *
10 * `radius` is kept in the signature because callers size the badge themselves;
11 * the image already carries its own transparent shape, so no clipping is
12 * applied by default.
13 */
14export function KernelMark({ size = 44 }: KernelMarkProps) {
15 return (
16 <img
17 src="/favicon.png"
18 alt=""
19 aria-hidden
20 draggable={false}
21 className="inline-block shrink-0 select-none object-contain"
22 style={{ width: size, height: size }}
23 />
24 )
25}
Added view/app/src/components/alert.tsx view/app/src/components/alert.tsx +26 −0 View view/app/src/components/alert.tsx
@@ -0,0 +1,26 @@
1import type { ReactNode } from 'react'
2
3export type AlertTone = 'error' | 'success' | 'info'
4
5const toneClasses: Record<AlertTone, string> = {
6 error: 'bg-error text-error-foreground',
7 success: 'bg-success text-success-foreground',
8 info: 'bg-info text-info-foreground',
9}
10
11export interface AlertProps {
12 tone?: AlertTone
13 children: ReactNode
14}
15
16/** Inline form-level feedback banner. */
17export function Alert({ tone = 'error', children }: AlertProps) {
18 return (
19 <p
20 role={tone === 'error' ? 'alert' : 'status'}
21 className={`w-full rounded-field px-3.5 py-2.5 font-sans text-[12.5px] leading-[1.5] ${toneClasses[tone]}`}
22 >
23 {children}
24 </p>
25 )
26}
Added view/app/src/components/index.ts view/app/src/components/index.ts +13 −0 View view/app/src/components/index.ts
@@ -0,0 +1,13 @@
1export * from './alert'
2export { KernelMark, type KernelMarkProps } from './brand'
3export * from './language-picker'
4export * from './markdown'
5export {
6 dialogInputClass as modalInputClass,
7 dialogLabelClass as modalLabelClass,
8 dialogTextareaClass as modalTextareaClass,
9 Modal,
10} from './modal'
11export * from './otp-input'
12export * from './panels'
13export * from './text-field'
Added view/app/src/components/language-picker.tsx view/app/src/components/language-picker.tsx +55 −0 View view/app/src/components/language-picker.tsx
@@ -0,0 +1,55 @@
1import { LOCALES, type Locale, localeLabel, setLocale, useT } from '@app/i18n'
2
3/**
4 * Language selector used by the settings modal.
5 *
6 * `value` is the persisted `users.language`; an unrecognized tag (e.g. someone
7 * who set `ja` for output) is kept as a locked extra option so that saving
8 * the form cannot silently erase it. Choosing a shipped locale writes the
9 * device choice immediately and returns it for the caller to persist.
10 *
11 * An empty value clears the account override and hands control back to the
12 * device/browser chain, mirroring a fresh account.
13 */
14export function LanguagePicker({
15 value,
16 onChange,
17 className,
18}: {
19 value: string
20 onChange: (next: Locale | null) => void
21 className: string
22}) {
23 const { t } = useT()
24 const known = LOCALES.includes(value as Locale)
25 const unknown = value && !known ? value : null
26
27 return (
28 <select
29 className={className}
30 value={value}
31 onChange={(event) => {
32 const raw = event.target.value
33 if (raw === '') {
34 onChange(null)
35 return
36 }
37 const next = raw as Locale
38 setLocale(next)
39 onChange(next)
40 }}
41 >
42 <option value="">{t('common.language.system')}</option>
43 {LOCALES.map((locale) => (
44 <option key={locale} value={locale}>
45 {localeLabel(locale)}
46 </option>
47 ))}
48 {unknown && (
49 <option value={unknown}>
50 {t('common.state.unknown')} · {unknown}
51 </option>
52 )}
53 </select>
54 )
55}
Added view/app/src/components/markdown.tsx view/app/src/components/markdown.tsx +44 −0 View view/app/src/components/markdown.tsx
@@ -0,0 +1,44 @@
1import { useSpaLinkClick } from '@app/lib'
2import DOMPurify from 'dompurify'
3import { marked } from 'marked'
4import { useMemo } from 'react'
5
6marked.use({ gfm: true, breaks: true })
7
8/**
9 * Markdown 渲染器。
10 *
11 * 模型输出的是 Markdown(总结、评审结论、release 正文),必须按 Markdown
12 * 渲染而不是当纯文本显示。模型输出属于不可信内容,因此渲染结果一律经
13 * DOMPurify 清洗:脚本、事件属性与 `javascript:` 链接都会被剥离。
14 */
15export function Markdown({
16 children,
17 className = '',
18 compact = false,
19 inline = false,
20}: {
21 children: string
22 className?: string
23 /** 行内发现等小字号场景。 */
24 compact?: boolean
25 /** 只要行内结果(标题、单行字段):无 `<p>` 包裹,渲染成 `<span>`。 */
26 inline?: boolean
27}) {
28 const html = useMemo(() => {
29 // `async: false` is what makes both calls synchronous; the cast only
30 // undoes TypeScript's widening of the option literal to `boolean`.
31 const raw = (inline ? marked.parseInline(children) : marked.parse(children, { async: false })) as string
32 return DOMPurify.sanitize(raw)
33 }, [children, inline])
34 // 渲染出的链接不允许直接跳转:站内走 react-router,站外开新标签页。
35 const onClick = useSpaLinkClick()
36 const Tag = inline ? 'span' : 'div'
37 return (
38 <Tag
39 className={`gitdata-markdown ${compact ? 'gitdata-markdown-compact' : ''} ${className}`}
40 onClick={onClick}
41 dangerouslySetInnerHTML={{ __html: html }}
42 />
43 )
44}
Added view/app/src/components/modal.tsx view/app/src/components/modal.tsx +103 −0 View view/app/src/components/modal.tsx
@@ -0,0 +1,103 @@
1import { useT } from '@app/i18n'
2import { X } from 'lucide-react'
3import { type ReactNode, useEffect, useId, useRef } from 'react'
4
5/**
6 * Generic modal chrome (overlay + panel + Escape/outside-click close), shared
7 * by feature areas that need layout the config-driven FormModal cannot express.
8 */
9export function Modal({
10 title,
11 description,
12 open,
13 onClose,
14 width = 'max-w-[620px]',
15 children,
16 footer,
17}: {
18 title: string
19 description?: string
20 open: boolean
21 onClose: () => void
22 width?: string
23 children: ReactNode
24 footer?: ReactNode
25}) {
26 const { t } = useT()
27 const panel = useRef<HTMLDivElement>(null)
28 const closeRef = useRef(onClose)
29 const titleId = useId()
30
31 useEffect(() => {
32 closeRef.current = onClose
33 }, [onClose])
34
35 useEffect(() => {
36 if (!open) return
37 const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
38 const previousOverflow = document.body.style.overflow
39 document.body.style.overflow = 'hidden'
40 const frame = requestAnimationFrame(() => {
41 const firstField = panel.current?.querySelector<HTMLElement>(
42 'input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
43 )
44 ;(firstField ?? panel.current)?.focus()
45 })
46 const onKey = (event: KeyboardEvent) => {
47 if (event.defaultPrevented) return
48 if (event.key === 'Escape') {
49 event.preventDefault()
50 closeRef.current()
51 }
52 }
53 window.addEventListener('keydown', onKey)
54 return () => {
55 cancelAnimationFrame(frame)
56 document.body.style.overflow = previousOverflow
57 window.removeEventListener('keydown', onKey)
58 if (previousFocus?.isConnected) previousFocus.focus()
59 }
60 }, [open])
61
62 if (!open) return null
63 return (
64 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
65 <div
66 ref={panel}
67 tabIndex={-1}
68 role="dialog"
69 aria-modal
70 aria-labelledby={titleId}
71 onClick={(event) => event.stopPropagation()}
72 className={`flex max-h-[86vh] w-full flex-col rounded-xl border-2 border-shell-hairline bg-shell-surface p-5 shadow-xl ${width}`}
73 >
74 <div className="mb-3 flex items-start justify-between gap-3">
75 <div className="min-w-0">
76 <h2 id={titleId} className="text-[16px] font-semibold">
77 {title}
78 </h2>
79 {description ? (
80 <p className="mt-1 text-[12.5px] leading-[1.5] text-shell-ink-muted">{description}</p>
81 ) : null}
82 </div>
83 <button
84 type="button"
85 aria-label={t('ui.close')}
86 onClick={onClose}
87 className="shrink-0 text-shell-ink-muted transition-colors hover:text-shell-ink"
88 >
89 <X className="h-4 w-4" />
90 </button>
91 </div>
92 <div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">{children}</div>
93 {footer ? <div className="mt-4 flex shrink-0 items-center justify-end gap-2">{footer}</div> : null}
94 </div>
95 </div>
96 )
97}
98
99export const dialogInputClass =
100 'h-10 w-full rounded-md border-2 border-shell-hairline bg-shell-field px-3 text-[13.5px] text-shell-ink outline-none transition-colors focus:border-shell-ink'
101export const dialogTextareaClass =
102 'w-full resize-y rounded-md border-2 border-shell-hairline bg-shell-field px-3 py-2 text-[13.5px] leading-6 outline-none transition-colors focus:border-shell-ink'
103export const dialogLabelClass = 'mb-1.5 block text-[12px] font-medium text-shell-ink'
Added view/app/src/components/otp-input.tsx view/app/src/components/otp-input.tsx +47 −0 View view/app/src/components/otp-input.tsx
@@ -0,0 +1,47 @@
1import { useRef, useState } from 'react'
2
3export interface OtpInputProps {
4 value: string
5 onChange: (value: string) => void
6 length?: number
7 disabled?: boolean
8 'aria-label'?: string
9}
10
11/** Segmented one-time-code field: 6 cells, dark-bordered active cell with orange caret. */
12export function OtpInput({ value, onChange, length = 6, disabled, ...aria }: OtpInputProps) {
13 const inputRef = useRef<HTMLInputElement>(null)
14 const [focused, setFocused] = useState(false)
15
16 return (
17 <div className="relative flex w-full gap-1.5 sm:gap-2.5" onClick={() => inputRef.current?.focus()}>
18 {Array.from({ length }, (_, i) => {
19 const digit = value[i] ?? ''
20 const isActive = focused && i === value.length && value.length < length
21 return (
22 <div
23 key={i}
24 className={`flex h-[58px] flex-1 items-center justify-center rounded-field border bg-card font-mono text-[22px] font-bold text-foreground transition-colors ${
25 isActive ? 'border-foreground' : 'border-input'
26 }`}
27 >
28 {digit || (isActive && <span className="h-6 w-[2px] animate-pulse bg-primary" />)}
29 </div>
30 )
31 })}
32 <input
33 ref={inputRef}
34 inputMode="numeric"
35 autoComplete="one-time-code"
36 maxLength={length}
37 value={value}
38 disabled={disabled}
39 onChange={(e) => onChange(e.target.value.replace(/\D/g, '').slice(0, length))}
40 onFocus={() => setFocused(true)}
41 onBlur={() => setFocused(false)}
42 className="absolute inset-0 size-full cursor-default opacity-0"
43 {...aria}
44 />
45 </div>
46 )
47}
Added view/app/src/components/panels.tsx view/app/src/components/panels.tsx +70 −0 View view/app/src/components/panels.tsx
@@ -0,0 +1,70 @@
1import { useT } from '@app/i18n'
2import { Check, Copy } from 'lucide-react'
3import { useState } from 'react'
4
5/** Shared empty / error / copy affordances for the repo views. */
6
7export function PanelNote({ text }: { text: string }) {
8 return <p className="px-2 py-6 text-center text-[13px] text-shell-ink-muted">{text}</p>
9}
10
11export function ErrorPanel({
12 status,
13 what,
14 onRetry,
15 note,
16}: {
17 status: number
18 what: string
19 onRetry: () => void
20 note?: string
21}) {
22 const { t } = useT()
23 const text =
24 status === 404
25 ? t('ui.notFoundOnRevision', { what })
26 : status === 401 || status === 403
27 ? t('ui.forbidden', { what })
28 : status === 0
29 ? t('ui.loadFailedWithNote', { what, note: note ?? t('error.status.0') })
30 : t('ui.loadFailedWithStatus', { what, status })
31 return (
32 <div className="flex flex-col items-center gap-3 py-10 text-center">
33 <p className="text-[13.5px]">{text}</p>
34 <button
35 type="button"
36 onClick={onRetry}
37 className="rounded-md border-2 border-shell-hairline bg-shell-field px-4 py-1.5 text-[13px] transition-colors hover:border-shell-ink"
38 >
39 {t('common.action.retry')}
40 </button>
41 </div>
42 )
43}
44
45/** Copy-to-clipboard chip used for object ids; falls back silently when the
46 * clipboard API is unavailable (insecure context / permission denied). */
47export function CopyChip({ text, label }: { text: string; label?: string }) {
48 const { t } = useT()
49 const [copied, setCopied] = useState(false)
50 return (
51 <button
52 type="button"
53 aria-label={`${t('common.action.copy')} ${label ?? text}`}
54 title={text}
55 onClick={async () => {
56 try {
57 await navigator.clipboard.writeText(text)
58 setCopied(true)
59 setTimeout(() => setCopied(false), 1500)
60 } catch {
61 /* clipboard unavailable */
62 }
63 }}
64 className="flex shrink-0 items-center gap-1 rounded bg-shell-active/70 px-1.5 py-0.5 font-mono text-[11px] text-shell-ink-muted transition-colors hover:text-shell-ink"
65 >
66 {copied ? <Check className="h-3 w-3" aria-hidden /> : <Copy className="h-3 w-3" aria-hidden />}
67 {copied ? t('common.action.copied') : (label ?? text)}
68 </button>
69 )
70}
Added view/app/src/components/text-field.tsx view/app/src/components/text-field.tsx +53 −0 View view/app/src/components/text-field.tsx
@@ -0,0 +1,53 @@
1import type { LucideIcon } from 'lucide-react'
2import { forwardRef, type InputHTMLAttributes, type ReactNode, useId } from 'react'
3
4export interface TextFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
5 label: string
6 icon?: LucideIcon
7 /** Right-side element in the label row (link, badge, timer, ...). */
8 aside?: ReactNode
9 /** Trailing interactive element inside the input box (reveal toggle, ...). */
10 trailing?: ReactNode
11 error?: string
12}
13
14/** Labelled input row matching the auth design: 10px mono label, 46px field. */
15export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(function TextField(
16 { label, icon: Icon, aside, trailing, error, id, ...props },
17 ref,
18) {
19 const autoId = useId()
20 const inputId = id ?? autoId
21 return (
22 <div className="flex w-full flex-col gap-[7px]">
23 <div className="flex w-full items-center justify-between">
24 <label
25 htmlFor={inputId}
26 className="font-mono text-[10px] font-bold tracking-[1.2px] text-muted-foreground uppercase"
27 >
28 {label}
29 </label>
30 {aside}
31 </div>
32 <div
33 className={`flex h-[46px] w-full items-center gap-2.5 rounded-field border bg-card px-3.5 transition-colors focus-within:border-foreground ${
34 error ? 'border-destructive' : 'border-input'
35 }`}
36 >
37 {Icon && <Icon size={15} className="shrink-0 text-muted-foreground" aria-hidden />}
38 <input
39 ref={ref}
40 id={inputId}
41 className="h-full w-full min-w-0 flex-1 bg-transparent text-[14.5px] text-foreground outline-none placeholder:text-muted-foreground/60"
42 {...props}
43 />
44 {trailing}
45 </div>
46 {error && (
47 <p role="alert" className="font-sans text-xs text-error-foreground">
48 {error}
49 </p>
50 )}
51 </div>
52 )
53})
Added view/app/src/ui/button.tsx view/app/src/ui/button.tsx +36 −0 View view/app/src/ui/button.tsx
@@ -0,0 +1,36 @@
1import { type ButtonHTMLAttributes, forwardRef } from 'react'
2
3export type ButtonVariant = 'primary' | 'secondary' | 'ghost'
4export type ButtonSize = 'default' | 'lg'
5
6export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
7 variant?: ButtonVariant
8 size?: ButtonSize
9}
10
11const variantClasses: Record<ButtonVariant, string> = {
12 primary:
13 'bg-primary text-primary-foreground font-mono font-bold tracking-[0.4px] hover:brightness-95 active:brightness-90',
14 secondary: 'border border-border bg-card text-foreground font-sans font-medium hover:bg-muted',
15 ghost: 'bg-transparent text-foreground hover:bg-muted',
16}
17
18const sizeClasses: Record<ButtonSize, string> = {
19 default: 'h-9 gap-2 rounded-field px-4 text-sm',
20 lg: 'h-12 gap-[9px] rounded-field px-6 text-sm',
21}
22
23/** Business-free UI kit primitive: no domain logic, no store, no requests. */
24export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
25 { variant = 'primary', size = 'default', className = '', type = 'button', ...props },
26 ref,
27) {
28 return (
29 <button
30 ref={ref}
31 type={type}
32 className={`inline-flex items-center justify-center transition-[filter,background-color] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
33 {...props}
34 />
35 )
36})
Added view/app/src/ui/checkbox.tsx view/app/src/ui/checkbox.tsx +29 −0 View view/app/src/ui/checkbox.tsx
@@ -0,0 +1,29 @@
1import { Check } from 'lucide-react'
2
3export interface CheckboxProps {
4 checked: boolean
5 onChange: (checked: boolean) => void
6 id?: string
7 'aria-label'?: string
8 disabled?: boolean
9}
10
11/** 18px square checkbox: orange fill + dark check when active (auth design). */
12export function Checkbox({ checked, onChange, id, disabled, ...aria }: CheckboxProps) {
13 return (
14 <button
15 type="button"
16 id={id}
17 role="checkbox"
18 aria-checked={checked}
19 disabled={disabled}
20 onClick={() => onChange(!checked)}
21 className={`flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 ${
22 checked ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-card'
23 }`}
24 {...aria}
25 >
26 {checked && <Check size={12} strokeWidth={3} aria-hidden />}
27 </button>
28 )
29}
Added view/app/src/ui/index.ts view/app/src/ui/index.ts +4 −0 View view/app/src/ui/index.ts
@@ -0,0 +1,4 @@
1export * from './button'
2export * from './checkbox'
3export * from './labeled-divider'
4export * from './reveal-toggle'
Added view/app/src/ui/labeled-divider.tsx view/app/src/ui/labeled-divider.tsx +16 −0 View view/app/src/ui/labeled-divider.tsx
@@ -0,0 +1,16 @@
1export interface LabeledDividerProps {
2 label: string
3}
4
5/** Thin hairline split by an uppercase mono caption (auth design). */
6export function LabeledDivider({ label }: LabeledDividerProps) {
7 return (
8 <div className="flex w-full items-center gap-3">
9 <div className="h-px flex-1 bg-border" />
10 <span className="shrink-0 font-mono text-[10px] font-bold tracking-[1.1px] text-muted-foreground uppercase">
11 {label}
12 </span>
13 <div className="h-px flex-1 bg-border" />
14 </div>
15 )
16}
Added view/app/src/ui/reveal-toggle.tsx view/app/src/ui/reveal-toggle.tsx +21 −0 View view/app/src/ui/reveal-toggle.tsx
@@ -0,0 +1,21 @@
1import { Eye, EyeOff } from 'lucide-react'
2
3export interface RevealToggleProps {
4 revealed: boolean
5 onToggle: () => void
6 label?: string
7}
8
9/** Trailing show/hide toggle for passphrase fields. */
10export function RevealToggle({ revealed, onToggle, label = 'passphrase' }: RevealToggleProps) {
11 return (
12 <button
13 type="button"
14 onClick={onToggle}
15 aria-label={revealed ? `Hide ${label}` : `Reveal ${label}`}
16 className="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
17 >
18 {revealed ? <EyeOff size={16} aria-hidden /> : <Eye size={16} aria-hidden />}
19 </button>
20 )
21}