feat(mobile): keep team, template, and report flows in sync with the server
Team, meeting, memo, template, command, and dictionary screens drifted from the server contract, and report submission could hang instead of confirming to the user. The screens now use the server responses directly. The retired Expo shell is removed; the React Native app is the mobile client. Gradle-generated vector-icon drawables are ignored rather than committed.
This commit is contained in:
parent
bb0e54dcee
commit
94d8bb8ebe
32 changed files with 310 additions and 2229 deletions
|
|
@ -29,11 +29,13 @@ import {
|
|||
acceptTeamInvite,
|
||||
cancelTeamInvite,
|
||||
createTeam,
|
||||
createTeamActivity,
|
||||
createTeamInvite,
|
||||
extractInviteToken,
|
||||
normalizeInviteEmail,
|
||||
normalizeInviteToken,
|
||||
normalizeInviteUrl,
|
||||
normalizeTeamActivity,
|
||||
normalizeTeamInvite,
|
||||
normalizeTeamMember,
|
||||
normalizeTeamName,
|
||||
|
|
@ -293,6 +295,57 @@ describe('team mutation safety', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('team activity feed contract', () => {
|
||||
test('normalizes a valid activity and rejects malformed rows', () => {
|
||||
const activity = normalizeTeamActivity({
|
||||
id: INVITE_ID,
|
||||
team_id: TEAM_ID,
|
||||
actor_id: USER_ID,
|
||||
kind: 'note',
|
||||
body: 'hello',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
expect(activity).toEqual({
|
||||
id: INVITE_ID,
|
||||
teamId: TEAM_ID,
|
||||
actorId: USER_ID,
|
||||
kind: 'note',
|
||||
body: 'hello',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
expect(() => normalizeTeamActivity({ id: 'not-a-uuid', team_id: TEAM_ID, kind: 'note' }))
|
||||
.toThrow(TeamServiceError)
|
||||
})
|
||||
|
||||
test('posts a trimmed note through the RPC and rejects empty notes', async () => {
|
||||
mockRpc.mockResolvedValue({
|
||||
data: {
|
||||
id: INVITE_ID,
|
||||
team_id: TEAM_ID,
|
||||
actor_id: USER_ID,
|
||||
kind: 'note',
|
||||
body: 'hello',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
const created = await createTeamActivity(TEAM_ID, 'note', ' hello ')
|
||||
expect(mockRpc).toHaveBeenCalledWith('create_team_activity', {
|
||||
p_team_id: TEAM_ID,
|
||||
p_kind: 'note',
|
||||
p_body: 'hello',
|
||||
p_metadata: {},
|
||||
})
|
||||
expect(created.body).toBe('hello')
|
||||
|
||||
mockRpc.mockClear()
|
||||
await expect(createTeamActivity(TEAM_ID, 'note', ' ')).rejects.toMatchObject({
|
||||
code: 'validation',
|
||||
})
|
||||
expect(mockRpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('team realtime contract', () => {
|
||||
test('filters every mutable team table by the selected team', () => {
|
||||
mockRealtimeOn.mockClear()
|
||||
|
|
@ -306,6 +359,7 @@ describe('team realtime contract', () => {
|
|||
{ event: '*', schema: 'public', table: 'team_members', filter: `team_id=eq.${TEAM_ID}` },
|
||||
{ event: '*', schema: 'public', table: 'team_invites', filter: `team_id=eq.${TEAM_ID}` },
|
||||
{ event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${TEAM_ID}` },
|
||||
{ event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${TEAM_ID}` },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'flex-end',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.62)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
keyboardArea: { width: '100%', justifyContent: 'flex-end' },
|
||||
sheet: {
|
||||
|
|
|
|||
|
|
@ -38,12 +38,30 @@ export interface TeamAcceptResult {
|
|||
alreadyMember: boolean
|
||||
}
|
||||
|
||||
export type TeamActivityKind =
|
||||
| 'note'
|
||||
| 'member_joined'
|
||||
| 'member_left'
|
||||
| 'invite_created'
|
||||
| 'meeting_shared'
|
||||
| 'document_shared'
|
||||
|
||||
export interface TeamActivityEntry {
|
||||
id: string
|
||||
teamId: string
|
||||
actorId: string | null
|
||||
kind: TeamActivityKind
|
||||
body: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface TeamDetail {
|
||||
team: Team
|
||||
currentRole: TeamRole
|
||||
members: TeamMemberDirectoryEntry[]
|
||||
invites: TeamInviteListEntry[]
|
||||
meetings: Meeting[]
|
||||
activities: TeamActivityEntry[]
|
||||
}
|
||||
|
||||
export type TeamServiceErrorCode =
|
||||
|
|
@ -262,6 +280,46 @@ export function normalizeTeamInvite(value: unknown): TeamInviteListEntry {
|
|||
}
|
||||
}
|
||||
|
||||
const TEAM_ACTIVITY_KINDS: readonly TeamActivityKind[] = [
|
||||
'note',
|
||||
'member_joined',
|
||||
'member_left',
|
||||
'invite_created',
|
||||
'meeting_shared',
|
||||
'document_shared',
|
||||
]
|
||||
|
||||
function isActivityKind(value: unknown): value is TeamActivityKind {
|
||||
return typeof value === 'string' && (TEAM_ACTIVITY_KINDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function normalizeTeamActivity(value: unknown): TeamActivityEntry {
|
||||
if (!isRecord(value)) {
|
||||
throw new TeamServiceError('invalid-response', 'Team activity response is not an object')
|
||||
}
|
||||
const actorId = value.actor_id ?? value.actorId ?? null
|
||||
const body = value.body ?? null
|
||||
if (
|
||||
typeof value.id !== 'string'
|
||||
|| !UUID_PATTERN.test(value.id)
|
||||
|| typeof value.team_id !== 'string'
|
||||
|| !isActivityKind(value.kind)
|
||||
|| (actorId !== null && typeof actorId !== 'string')
|
||||
|| (body !== null && typeof body !== 'string')
|
||||
|| !isIsoDate(value.created_at)
|
||||
) {
|
||||
throw new TeamServiceError('invalid-response', 'Team activity response is malformed')
|
||||
}
|
||||
return {
|
||||
id: value.id,
|
||||
teamId: value.team_id,
|
||||
actorId,
|
||||
kind: value.kind,
|
||||
body,
|
||||
createdAt: value.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
function errorDetails(error: unknown): { code: string; message: string } {
|
||||
const candidate = error as {
|
||||
code?: unknown
|
||||
|
|
@ -441,11 +499,63 @@ async function listTeamInvites(teamId: string): Promise<TeamInviteListEntry[]> {
|
|||
return data.map(normalizeTeamInvite)
|
||||
}
|
||||
|
||||
export async function listTeamActivities(teamId: string, limit = 50): Promise<TeamActivityEntry[]> {
|
||||
requireUuid(teamId, 'Team')
|
||||
const capped = Math.max(1, Math.min(Math.floor(limit), 100))
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('team_activities')
|
||||
.select('*')
|
||||
.eq('team_id', teamId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(capped)
|
||||
if (error !== null) throw error
|
||||
return (data ?? []).map(normalizeTeamActivity)
|
||||
} catch (error) {
|
||||
throw toTeamServiceError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTeamActivity(
|
||||
teamId: string,
|
||||
kind: TeamActivityKind,
|
||||
body: string | null,
|
||||
): Promise<TeamActivityEntry> {
|
||||
requireUuid(teamId, 'Team')
|
||||
if (!isActivityKind(kind)) {
|
||||
throw new TeamServiceError('validation', 'Team activity kind is invalid')
|
||||
}
|
||||
const trimmed = typeof body === 'string' ? body.trim() : ''
|
||||
if (kind === 'note' && trimmed.length === 0) {
|
||||
throw new TeamServiceError('validation', 'Team activity body is required')
|
||||
}
|
||||
if (trimmed.length > 2000) {
|
||||
throw new TeamServiceError('validation', 'Team activity body is too long')
|
||||
}
|
||||
const result = await invokeRpc('create_team_activity', {
|
||||
p_team_id: teamId,
|
||||
p_kind: kind,
|
||||
p_body: trimmed === '' ? null : trimmed,
|
||||
p_metadata: {},
|
||||
})
|
||||
if (!isRecord(result) || typeof result.id !== 'string' || !UUID_PATTERN.test(result.id)) {
|
||||
throw new TeamServiceError('invalid-response', 'Team activity response is malformed')
|
||||
}
|
||||
return {
|
||||
id: result.id,
|
||||
teamId,
|
||||
actorId: typeof result.actor_id === 'string' ? result.actor_id : null,
|
||||
kind,
|
||||
body: trimmed === '' ? null : trimmed,
|
||||
createdAt: typeof result.created_at === 'string' ? result.created_at : new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamDetail(userId: string, teamId: string): Promise<TeamDetail> {
|
||||
requireUuid(userId, 'Authenticated user')
|
||||
requireUuid(teamId, 'Team')
|
||||
try {
|
||||
const [teamResult, members, meetingsResult] = await Promise.all([
|
||||
const [teamResult, members, meetingsResult, activitiesResult] = await Promise.all([
|
||||
supabase.from('teams').select('*').eq('id', teamId).maybeSingle(),
|
||||
listTeamMembers(teamId),
|
||||
supabase
|
||||
|
|
@ -454,12 +564,19 @@ export async function getTeamDetail(userId: string, teamId: string): Promise<Tea
|
|||
.eq('team_id', teamId)
|
||||
.order('started_at', { ascending: false })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('team_activities')
|
||||
.select('*')
|
||||
.eq('team_id', teamId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50),
|
||||
])
|
||||
if (teamResult.error !== null) throw teamResult.error
|
||||
if (teamResult.data === null) {
|
||||
throw new TeamServiceError('not-found', 'Team was not found')
|
||||
}
|
||||
if (meetingsResult.error !== null) throw meetingsResult.error
|
||||
if (activitiesResult.error !== null) throw activitiesResult.error
|
||||
const team = normalizeTeam(teamResult.data)
|
||||
const ownMembership = members.find((member) => member.userId === userId)
|
||||
if (ownMembership === undefined) {
|
||||
|
|
@ -473,7 +590,8 @@ export async function getTeamDetail(userId: string, teamId: string): Promise<Tea
|
|||
if (meetings.some((meeting) => meeting.team_id !== teamId)) {
|
||||
throw new TeamServiceError('invalid-response', 'Meeting response crossed team boundary')
|
||||
}
|
||||
return { team, currentRole: ownMembership.role, members, invites, meetings }
|
||||
const activities = (activitiesResult.data ?? []).map(normalizeTeamActivity)
|
||||
return { team, currentRole: ownMembership.role, members, invites, meetings, activities }
|
||||
} catch (error) {
|
||||
throw toTeamServiceError(error)
|
||||
}
|
||||
|
|
@ -681,6 +799,11 @@ export function subscribeToTeam(
|
|||
{ event: '*', schema: 'public', table: 'meetings', filter: `team_id=eq.${teamId}` },
|
||||
onRemoteChange,
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'team_activities', filter: `team_id=eq.${teamId}` },
|
||||
onRemoteChange,
|
||||
)
|
||||
.subscribe(onStatus)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -840,7 +840,7 @@ function createStyles(palette: Palette) {
|
|||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 18,
|
||||
backgroundColor: 'rgba(0,0,0,0.72)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: { maxHeight: '88%', padding: 0, overflow: 'hidden' },
|
||||
modalContent: { padding: 18, gap: 16 },
|
||||
|
|
|
|||
|
|
@ -603,7 +603,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
output: { gap: 7, padding: 12, borderWidth: 1, borderRadius: 9, borderColor: palette.accent.main },
|
||||
outputHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
outputText: { lineHeight: 21 },
|
||||
backdrop: { flex: 1, justifyContent: 'flex-end', padding: 18, backgroundColor: 'rgba(0,0,0,0.65)' },
|
||||
backdrop: { flex: 1, justifyContent: 'flex-end', padding: 18, backgroundColor: palette.scrim },
|
||||
editor: { gap: 13, maxHeight: '92%' },
|
||||
editorActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 9 },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -673,7 +673,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.72)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: {
|
||||
width: '100%',
|
||||
|
|
|
|||
|
|
@ -1152,7 +1152,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.62)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: { gap: 14, width: '100%', maxWidth: 640, alignSelf: 'center' },
|
||||
editorInput: {
|
||||
|
|
|
|||
|
|
@ -772,7 +772,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.62)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: { gap: 14, maxWidth: 560, maxHeight: '92%', width: '100%', alignSelf: 'center' },
|
||||
modalScroll: { flexShrink: 1 },
|
||||
|
|
|
|||
|
|
@ -565,7 +565,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
tag: { minHeight: 38, justifyContent: 'center', paddingHorizontal: 10, borderRadius: 999, backgroundColor: palette.accent.dim },
|
||||
addRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
addInput: { flex: 1, marginHorizontal: 0 },
|
||||
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(0,0,0,0.68)' },
|
||||
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: palette.scrim },
|
||||
modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 },
|
||||
actions: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
flexButton: { flex: 1 },
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useMobilePreferences } from '../lib/preferences-context'
|
|||
import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'
|
||||
import {
|
||||
cancelTeamInvite,
|
||||
createTeamActivity,
|
||||
createTeamInvite,
|
||||
deleteTeamRevisionSafe,
|
||||
getTeamDetail,
|
||||
|
|
@ -70,6 +71,8 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
|
|||
const [inviteRole, setInviteRole] = useState<'admin' | 'member'>('member')
|
||||
const [editorError, setEditorError] = useState<TeamServiceErrorCode | null>(null)
|
||||
const [inviteResult, setInviteResult] = useState<TeamInviteResult | null>(null)
|
||||
const [activityBody, setActivityBody] = useState('')
|
||||
const [postingActivity, setPostingActivity] = useState(false)
|
||||
|
||||
const load = useCallback(async (pull = false): Promise<void> => {
|
||||
const requestGeneration = ++generation.current
|
||||
|
|
@ -123,6 +126,29 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
|
|||
const canAdminister = detail?.currentRole === 'owner' || detail?.currentRole === 'admin'
|
||||
const isOwner = detail?.currentRole === 'owner'
|
||||
|
||||
const memberNameById = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const member of detail?.members ?? []) {
|
||||
map.set(member.userId, memberLabel(member, t('mobile.teams.unnamedMember')))
|
||||
}
|
||||
return map
|
||||
}, [detail, t])
|
||||
|
||||
const postActivity = async (): Promise<void> => {
|
||||
const body = activityBody.trim()
|
||||
if (user === null || body.length === 0 || postingActivity) return
|
||||
setPostingActivity(true)
|
||||
try {
|
||||
await createTeamActivity(teamId, 'note', body)
|
||||
setActivityBody('')
|
||||
await load(false)
|
||||
} catch (postError) {
|
||||
Alert.alert(t('mobile.teams.activity'), teamErrorMessage(teamErrorCode(postError), t))
|
||||
} finally {
|
||||
setPostingActivity(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openRename = (): void => {
|
||||
if (detail === null) return
|
||||
setName(detail.team.name)
|
||||
|
|
@ -523,6 +549,45 @@ export default function TeamDetailScreen({ route }: TeamDetailScreenProps): Reac
|
|||
))}
|
||||
</ThemeCard>
|
||||
|
||||
<ThemeCard style={styles.section} testID="team-activity">
|
||||
<ThemeText variant="heading">{t('mobile.teams.activity')}</ThemeText>
|
||||
<TextInput
|
||||
value={activityBody}
|
||||
onChangeText={setActivityBody}
|
||||
placeholder={t('mobile.teams.activityPlaceholder')}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
style={styles.activityInput}
|
||||
testID="team-activity-input"
|
||||
/>
|
||||
<ThemeButton
|
||||
label={postingActivity ? t('mobile.teams.posting') : t('mobile.teams.post')}
|
||||
variant="secondary"
|
||||
disabled={postingActivity || activityBody.trim().length === 0}
|
||||
onPress={() => { void postActivity() }}
|
||||
testID="team-activity-post"
|
||||
/>
|
||||
{detail.activities.length === 0 ? (
|
||||
<ThemeText color="muted">{t('mobile.teams.noActivity')}</ThemeText>
|
||||
) : detail.activities.map((activity) => (
|
||||
<View key={activity.id} style={styles.activityRow} testID={`team-activity-${activity.id}`}>
|
||||
<ThemeText variant="small" color="muted">
|
||||
{activity.actorId !== null
|
||||
? memberNameById.get(activity.actorId) ?? t('mobile.teams.unnamedMember')
|
||||
: t('mobile.teams.system')}
|
||||
{' · '}
|
||||
{formatDate(new Date(activity.createdAt), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</ThemeText>
|
||||
{activity.body !== null && activity.body.length > 0 && (
|
||||
<ThemeText>{activity.body}</ThemeText>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ThemeCard>
|
||||
|
||||
<ThemeCard style={styles.section}>
|
||||
<ThemeText variant="heading">{t('mobile.teams.management')}</ThemeText>
|
||||
{isOwner ? (
|
||||
|
|
@ -746,11 +811,30 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
backgroundColor: palette.bg.inset,
|
||||
gap: 5,
|
||||
},
|
||||
activityInput: {
|
||||
minHeight: 52,
|
||||
maxHeight: 140,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: palette.border.strong,
|
||||
borderRadius: 10,
|
||||
backgroundColor: palette.bg.inset,
|
||||
color: palette.text.primary,
|
||||
fontSize: 15,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
activityRow: {
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
backgroundColor: palette.bg.inset,
|
||||
gap: 4,
|
||||
},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.62)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 },
|
||||
input: {
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
backgroundColor: 'rgba(0,0,0,0.62)',
|
||||
backgroundColor: palette.scrim,
|
||||
},
|
||||
modalCard: { width: '100%', maxWidth: 560, alignSelf: 'center', gap: 14 },
|
||||
input: {
|
||||
|
|
|
|||
|
|
@ -685,7 +685,7 @@ function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
|||
badge: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 999, backgroundColor: palette.accent.dim },
|
||||
actions: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
flexButton: { flex: 1 },
|
||||
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 16, backgroundColor: 'rgba(0,0,0,0.68)' },
|
||||
modalBackdrop: { flex: 1, justifyContent: 'center', padding: 16, backgroundColor: palette.scrim },
|
||||
modalList: { width: '100%', maxWidth: 680, maxHeight: '94%', alignSelf: 'center', borderRadius: 14, backgroundColor: palette.bg.card },
|
||||
modalCard: { padding: 18, gap: 14 },
|
||||
editorSection: { gap: 14 },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,19 @@ export interface MobileThemePalette {
|
|||
green: string
|
||||
blue: string
|
||||
}
|
||||
/** v3: 시맨틱 상태 역할 (액센트와 분리) */
|
||||
status: {
|
||||
success: string
|
||||
successBg: string
|
||||
warning: string
|
||||
warningBg: string
|
||||
danger: string
|
||||
dangerBg: string
|
||||
info: string
|
||||
infoBg: string
|
||||
}
|
||||
/** v3: 모달 뒤 스크림 (테마 반응) */
|
||||
scrim: string
|
||||
led: {
|
||||
off: string
|
||||
}
|
||||
|
|
@ -96,6 +109,17 @@ const DARK_PALETTE: MobileThemePalette = {
|
|||
green: '#4ade80',
|
||||
blue: '#60a5fa',
|
||||
},
|
||||
status: {
|
||||
success: '#4ade80',
|
||||
successBg: 'rgba(74, 222, 128, 0.14)',
|
||||
warning: '#fbbf24',
|
||||
warningBg: 'rgba(251, 191, 36, 0.14)',
|
||||
danger: '#f87171',
|
||||
dangerBg: 'rgba(248, 113, 113, 0.14)',
|
||||
info: '#60a5fa',
|
||||
infoBg: 'rgba(96, 165, 250, 0.14)',
|
||||
},
|
||||
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||
led: { off: '#111111' },
|
||||
shadow: '#000000',
|
||||
}
|
||||
|
|
@ -140,6 +164,17 @@ const LIGHT_PALETTE: MobileThemePalette = {
|
|||
green: '#167044',
|
||||
blue: '#175cd3',
|
||||
},
|
||||
status: {
|
||||
success: '#15803d',
|
||||
successBg: 'rgba(21, 128, 61, 0.10)',
|
||||
warning: '#a25200',
|
||||
warningBg: 'rgba(162, 82, 0, 0.10)',
|
||||
danger: '#b42318',
|
||||
dangerBg: 'rgba(180, 35, 24, 0.10)',
|
||||
info: '#175cd3',
|
||||
infoBg: 'rgba(23, 92, 211, 0.10)',
|
||||
},
|
||||
scrim: 'rgba(0, 0, 0, 0.4)',
|
||||
led: { off: '#c6c6c2' },
|
||||
shadow: '#4b4b50',
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue