Skip to content
Clone

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
parent 71e93e6 commit 0cf4fcc Browse files

17 files changed

+741 −0

File tree 17
Added view/app/src/blocks/app-shell.tsx 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 view/app/src/blocks/index.ts
@@ -0,0 +1 @@
1export * from './app-shell'
Added view/app/src/components/brand/index.ts 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}