Merge feature/chat-ui-light-mode-sync
This commit is contained in:
@@ -188,7 +188,7 @@ export default function DimensionTags({
|
|||||||
{dimensions.length === 0 && !disabled && (
|
{dimensions.length === 0 && !disabled && (
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#555',
|
color: 'var(--rah-text-muted)',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -211,9 +211,9 @@ export default function DimensionTags({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '3px',
|
gap: '3px',
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
color: '#d1d5db',
|
color: 'var(--rah-text-base)',
|
||||||
background: '#1a1a1a',
|
background: 'var(--rah-bg-active)',
|
||||||
border: '1px solid #333',
|
border: '1px solid var(--rah-border-strong)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
cursor: disabled ? 'default' : 'grab',
|
cursor: disabled ? 'default' : 'grab',
|
||||||
@@ -222,11 +222,11 @@ export default function DimensionTags({
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
e.currentTarget.style.borderColor = '#555';
|
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.borderColor = '#333';
|
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{dimension}</span>
|
<span>{dimension}</span>
|
||||||
@@ -243,14 +243,14 @@ export default function DimensionTags({
|
|||||||
style={{
|
style={{
|
||||||
padding: '0 2px',
|
padding: '0 2px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#666',
|
color: 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
marginLeft: '2px'
|
marginLeft: '2px'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
onMouseEnter={(e) => { e.currentTarget.style.color = '#ff6b6b'; }}
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.color = '#666'; }}
|
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--rah-text-muted)'; }}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -270,21 +270,21 @@ export default function DimensionTags({
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#666',
|
color: 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px dashed #333',
|
border: '1px dashed var(--rah-border-strong)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.color = '#999';
|
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||||
e.currentTarget.style.borderColor = '#444';
|
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.color = '#666';
|
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||||
e.currentTarget.style.borderColor = '#333';
|
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+{hiddenCount} more
|
+{hiddenCount} more
|
||||||
@@ -302,21 +302,21 @@ export default function DimensionTags({
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
color: '#666',
|
color: 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px dashed #333',
|
border: '1px dashed var(--rah-border-strong)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s'
|
transition: 'all 0.2s'
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.color = '#999';
|
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||||
e.currentTarget.style.borderColor = '#444';
|
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.color = '#666';
|
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||||
e.currentTarget.style.borderColor = '#333';
|
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
show less
|
show less
|
||||||
@@ -340,18 +340,18 @@ export default function DimensionTags({
|
|||||||
lineHeight: 1,
|
lineHeight: 1,
|
||||||
color: 'var(--rah-text-muted)',
|
color: 'var(--rah-text-muted)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: '1px dashed #333',
|
border: '1px dashed var(--rah-border-strong)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'color 120ms ease, border-color 120ms ease',
|
transition: 'color 120ms ease, border-color 120ms ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
e.currentTarget.style.color = 'var(--rah-text-soft)';
|
||||||
e.currentTarget.style.borderColor = '#555';
|
e.currentTarget.style.borderColor = 'var(--rah-border-stronger)';
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
e.currentTarget.style.color = 'var(--rah-text-muted)';
|
||||||
e.currentTarget.style.borderColor = '#333';
|
e.currentTarget.style.borderColor = 'var(--rah-border-strong)';
|
||||||
}}
|
}}
|
||||||
title="Add dimension"
|
title="Add dimension"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function MarkdownRenderer({ content, streaming, onNodeClick }: Ma
|
|||||||
|
|
||||||
const segments = splitCodeBlocks(content);
|
const segments = splitCodeBlocks(content);
|
||||||
return (
|
return (
|
||||||
<div style={{ color: '#e5e5e5', fontSize: 16, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
<div style={{ color: 'var(--rah-text-base)', fontSize: 16, lineHeight: 1.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
{segments.map((seg, i) =>
|
{segments.map((seg, i) =>
|
||||||
seg.type === 'code' ? (
|
seg.type === 'code' ? (
|
||||||
<CodeBlock key={i} language={seg.lang} code={seg.text} />
|
<CodeBlock key={i} language={seg.lang} code={seg.text} />
|
||||||
@@ -41,12 +41,12 @@ function CodeBlock({ code, language }: { code: string; language?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ margin: '8px 0' }}>
|
<div style={{ margin: '8px 0' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#0f0f0f', border: '1px solid #2a2a2a', borderRadius: 6,
|
background: 'var(--rah-bg-base)', border: '1px solid var(--rah-border-strong)', borderRadius: 6,
|
||||||
padding: 8, overflowX: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12
|
padding: 8, overflowX: 'auto', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12
|
||||||
}}>
|
}}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 6 }}>
|
||||||
<span style={{ color: '#8a8a8a', fontSize: 11 }}>{language || 'code'}</span>
|
<span style={{ color: 'var(--rah-text-soft)', fontSize: 11 }}>{language || 'code'}</span>
|
||||||
<button onClick={handleCopy} style={{ marginLeft: 'auto', fontSize: 11, color: '#8a8a8a', background: 'transparent', border: '1px solid #2a2a2a', borderRadius: 4, padding: '1px 6px', cursor: 'pointer' }}>
|
<button onClick={handleCopy} style={{ marginLeft: 'auto', fontSize: 11, color: 'var(--rah-text-soft)', background: 'transparent', border: '1px solid var(--rah-border-strong)', borderRadius: 4, padding: '1px 6px', cursor: 'pointer' }}>
|
||||||
{copied ? 'Copied' : 'Copy'}
|
{copied ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,10 +68,10 @@ function renderTextWithFormatting(text: string, onNodeClick?: (nodeId: number) =
|
|||||||
<div key={`quote-${segIdx}`} style={{
|
<div key={`quote-${segIdx}`} style={{
|
||||||
margin: '12px 0',
|
margin: '12px 0',
|
||||||
padding: '10px 14px',
|
padding: '10px 14px',
|
||||||
borderLeft: '3px solid #333',
|
borderLeft: '3px solid var(--rah-border-strong)',
|
||||||
background: '#0f0f0f',
|
background: 'var(--rah-bg-base)',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
color: '#b8b8b8',
|
color: 'var(--rah-text-secondary)',
|
||||||
position: 'relative'
|
position: 'relative'
|
||||||
}}>
|
}}>
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -79,7 +79,7 @@ function renderTextWithFormatting(text: string, onNodeClick?: (nodeId: number) =
|
|||||||
top: 8,
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
color: '#3a3a3a',
|
color: 'var(--rah-border-stronger)',
|
||||||
lineHeight: 1
|
lineHeight: 1
|
||||||
}}>"</span>
|
}}>"</span>
|
||||||
<div style={{ paddingLeft: 12 }}>
|
<div style={{ paddingLeft: 12 }}>
|
||||||
@@ -155,10 +155,10 @@ function parseInlineFormatting(text: string, onNodeClick?: (nodeId: number) => v
|
|||||||
<strong key={`bold-${idx}`} style={{
|
<strong key={`bold-${idx}`} style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
textDecorationColor: '#4a4a4a',
|
textDecorationColor: 'var(--rah-border-stronger)',
|
||||||
textDecorationThickness: '1px',
|
textDecorationThickness: '1px',
|
||||||
textUnderlineOffset: '2px',
|
textUnderlineOffset: '2px',
|
||||||
color: '#f0f0f0'
|
color: 'var(--rah-text-active)'
|
||||||
}}>
|
}}>
|
||||||
{parseAndRenderContent(part.text, onNodeClick)}
|
{parseAndRenderContent(part.text, onNodeClick)}
|
||||||
</strong>
|
</strong>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function NodeLabel({ id, title, dimensions, onNodeClick }: NodeLabelProps) {
|
|||||||
<span style={{
|
<span style={{
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
color: '#e5e5e5'
|
color: 'var(--rah-text-base)'
|
||||||
}}>
|
}}>
|
||||||
{truncatedTitle}
|
{truncatedTitle}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ export default function SourceChip({ url, domain }: SourceChipProps) {
|
|||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 6,
|
gap: 6,
|
||||||
background: '#1a1a1a',
|
background: 'var(--rah-bg-panel)',
|
||||||
border: '1px solid #2a2a2a',
|
border: '1px solid var(--rah-border)',
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
padding: '2px 8px',
|
padding: '2px 8px',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: '#bdbdbd',
|
color: 'var(--rah-text-secondary)',
|
||||||
lineHeight: 1
|
lineHeight: 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -126,29 +126,29 @@ export default function SkillsPane({
|
|||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<span style={{ color: '#ccc', fontSize: '13px', fontWeight: 500 }}>{selectedSkill.name}</span>
|
<span style={{ color: 'var(--rah-text-base)', fontSize: '13px', fontWeight: 500 }}>{selectedSkill.name}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#666', fontSize: '11px' }}>{skills.length} skills</span>
|
<span style={{ color: 'var(--rah-text-muted)', fontSize: '11px' }}>{skills.length} skills</span>
|
||||||
)}
|
)}
|
||||||
</PaneHeader>
|
</PaneHeader>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }} ref={detailScrollRef}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '12px' }} ref={detailScrollRef}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
|
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>Loading...</div>
|
||||||
) : selectedSkill ? (
|
) : selectedSkill ? (
|
||||||
<div style={{ width: '100%', maxWidth: '980px', margin: '0 auto' }}>
|
<div style={{ width: '100%', maxWidth: '980px', margin: '0 auto' }}>
|
||||||
<div style={{ marginBottom: '12px' }}>
|
<div style={{ marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#eee', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div>
|
<div style={{ color: 'var(--rah-text-active)', fontSize: '16px', fontWeight: 600 }}>{selectedSkill.name}</div>
|
||||||
<div style={{ color: '#888', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div>
|
<div style={{ color: 'var(--rah-text-soft)', fontSize: '13px', lineHeight: 1.4, marginTop: '6px' }}>{selectedSkill.description}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ borderTop: '1px solid #222', margin: '12px 0' }} />
|
<div style={{ borderTop: '1px solid var(--rah-border)', margin: '12px 0' }} />
|
||||||
<SkillMarkdown content={selectedSkill.content} />
|
<SkillMarkdown content={selectedSkill.content} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
{skills.length === 0 ? (
|
{skills.length === 0 ? (
|
||||||
<div style={{ color: '#555', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
<div style={{ color: 'var(--rah-text-muted)', fontSize: '13px', textAlign: 'center', paddingTop: '24px' }}>
|
||||||
No skills found
|
No skills found
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import remarkGfm from 'remark-gfm';
|
|||||||
|
|
||||||
export default function SkillMarkdown({ content }: { content: string }) {
|
export default function SkillMarkdown({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="skill-content" style={{ color: '#ccc', fontSize: '13px', lineHeight: '1.6' }}>
|
<div className="skill-content" style={{ color: 'var(--rah-text-secondary)', fontSize: '13px', lineHeight: '1.6' }}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
h1: ({ children }) => (
|
h1: ({ children }) => (
|
||||||
<h1 style={{ fontSize: '18px', fontWeight: 600, color: '#eee', margin: '0 0 16px 0' }}>{children}</h1>
|
<h1 style={{ fontSize: '18px', fontWeight: 600, color: 'var(--rah-text-active)', margin: '0 0 16px 0' }}>{children}</h1>
|
||||||
),
|
),
|
||||||
h2: ({ children }) => (
|
h2: ({ children }) => (
|
||||||
<h2 style={{ fontSize: '15px', fontWeight: 600, color: '#ddd', margin: '20px 0 8px 0' }}>{children}</h2>
|
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--rah-text-base)', margin: '20px 0 8px 0' }}>{children}</h2>
|
||||||
),
|
),
|
||||||
h3: ({ children }) => (
|
h3: ({ children }) => (
|
||||||
<h3 style={{ fontSize: '14px', fontWeight: 600, color: '#ccc', margin: '16px 0 6px 0' }}>{children}</h3>
|
<h3 style={{ fontSize: '14px', fontWeight: 600, color: 'var(--rah-text-secondary)', margin: '16px 0 6px 0' }}>{children}</h3>
|
||||||
),
|
),
|
||||||
p: ({ children }) => (
|
p: ({ children }) => (
|
||||||
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
|
<p style={{ margin: '0 0 12px 0' }}>{children}</p>
|
||||||
@@ -36,11 +36,11 @@ export default function SkillMarkdown({ content }: { content: string }) {
|
|||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
style={{
|
style={{
|
||||||
background: '#1a1a1a',
|
background: 'var(--rah-bg-active)',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
color: '#22c55e',
|
color: 'var(--rah-accent-green)',
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -52,13 +52,13 @@ export default function SkillMarkdown({ content }: { content: string }) {
|
|||||||
<code
|
<code
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
background: '#0d0d0d',
|
background: 'var(--rah-bg-panel)',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
margin: '0 0 12px 0',
|
margin: '0 0 12px 0',
|
||||||
color: '#aaa',
|
color: 'var(--rah-text-soft)',
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -71,18 +71,18 @@ export default function SkillMarkdown({ content }: { content: string }) {
|
|||||||
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
|
<pre style={{ margin: '0 0 12px 0' }}>{children}</pre>
|
||||||
),
|
),
|
||||||
strong: ({ children }) => (
|
strong: ({ children }) => (
|
||||||
<strong style={{ color: '#eee', fontWeight: 600 }}>{children}</strong>
|
<strong style={{ color: 'var(--rah-text-active)', fontWeight: 600 }}>{children}</strong>
|
||||||
),
|
),
|
||||||
hr: () => (
|
hr: () => (
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2a2a', margin: '16px 0' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid var(--rah-border-strong)', margin: '16px 0' }} />
|
||||||
),
|
),
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
<blockquote
|
<blockquote
|
||||||
style={{
|
style={{
|
||||||
borderLeft: '3px solid #333',
|
borderLeft: '3px solid var(--rah-border-strong)',
|
||||||
paddingLeft: '12px',
|
paddingLeft: '12px',
|
||||||
margin: '0 0 12px 0',
|
margin: '0 0 12px 0',
|
||||||
color: '#999',
|
color: 'var(--rah-text-muted)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
Reference in New Issue
Block a user