@tastic/profile 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,417 @@
1
+ import { useAutoPaperTheme } from '@rific/auto-paper'
2
+ import { TouchableRipple } from '@rific/feedback-press'
3
+ import { useFocusChain } from '@rific/focus-chain'
4
+ import { useToast } from '@rific/toaster'
5
+ import { InlineColorPicker, PopoverHost, usePopoverHost } from '@tastic/hud'
6
+ import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
7
+ import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, View } from 'react-native'
8
+ import { Button, Icon, Portal, Text, TextInput } from 'react-native-paper'
9
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
+
11
+ import { ProfileChip } from './ProfileChip'
12
+ import { isValidTag, MAX_PROFILE_NAME_LENGTH } from './profilesValidation'
13
+ import { Profile } from './types'
14
+
15
+ // Shared by the New Profile row's own dashed circle, every profile row's own ProfileChip, the edit
16
+ // row's color trigger, and its tag field — one size for every circle on this screen, rather than
17
+ // each picking its own.
18
+ const CHIP_SIZE = 40
19
+
20
+ interface ProfileEditPatch {
21
+ name: string
22
+ color: string
23
+ tag: string
24
+ }
25
+
26
+ export interface ProfilesManagerProps {
27
+ profiles: Profile[]
28
+ // The color a brand-new profile's draft starts on — this component doesn't have (and shouldn't
29
+ // invent) an opinion about the host app's own palette, so this is required rather than defaulted.
30
+ defaultColor: string
31
+ onCreate: (input: ProfileEditPatch) => void
32
+ onSave: (id: string, patch: ProfileEditPatch) => void
33
+ // Irreversible — this only fires after this component's own confirmation card, not on the first
34
+ // tap. If the host app tracks anything else keyed by profile id (stats, history), clear that on
35
+ // its own side when this fires.
36
+ onDelete: (id: string) => void
37
+ // Rendered inline, before the "Profiles" title, in this component's own header row — a plain
38
+ // rendering slot, not an onBack-style callback: this component doesn't know or care that it's a
39
+ // back button, only that the host wants something shown there. Omit to hide the slot.
40
+ headerLeft?: ReactNode
41
+ }
42
+
43
+ type Row = { kind: 'profile'; profile: Profile } | { kind: 'new' }
44
+
45
+ // First letter of the name, uppercased — the tag's own default for as long as it hasn't been typed
46
+ // into directly (see the tagManuallySet state below). Empty name -> empty tag, same as typing
47
+ // nothing into the tag field yourself would leave it.
48
+ function deriveTag(name: string): string {
49
+ const trimmed = name.trim()
50
+ return trimmed ? trimmed[0].toUpperCase() : ''
51
+ }
52
+
53
+ // The profile roster itself — create/rename/recolor/retag/delete, editing inline right in the row
54
+ // you tapped — with no navigation of its own: just data in (`profiles`) and callbacks out, plus
55
+ // this file's own internal edit-draft state. Deliberately router-agnostic (an `onBack`-style prop
56
+ // would still assume *some* host owns navigation; this component doesn't even go that far), so any
57
+ // consuming screen just renders it as its body inside its own routed wrapper (back button, native
58
+ // push/pop transition, whatever persistence hook backs `profiles`/`onCreate`/`onSave`/`onDelete`).
59
+ export function ProfilesManager({ profiles, defaultColor, onCreate, onSave, onDelete, headerLeft }: ProfilesManagerProps) {
60
+ const { dark, colors } = useAutoPaperTheme()
61
+ const insets = useSafeAreaInsets()
62
+ const { error: showErrorToast } = useToast()
63
+ // Only one row is ever in edit mode at a time (see commitEdit, called before any new row opens),
64
+ // so every EditRow instance can safely share this one host instead of each needing its own.
65
+ const host = usePopoverHost()
66
+ const fg = dark ? '#FFFFFF' : '#000000'
67
+ const fgMuted = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.5)'
68
+ const cardBg = dark ? '#111111' : '#F2F2F2'
69
+ const cardBorder = dark ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'
70
+ // A contained neutral, not an outlined one — reads as "the other button" next to Delete's solid
71
+ // danger fill, rather than looking unfinished/secondary beside it.
72
+ const cancelBg = dark ? '#2A2A2A' : '#E0E0E0'
73
+ const cancelFg = dark ? '#FFFFFF' : '#000000'
74
+
75
+ // 'new' clears back to null once a commit resolves — there's only ever one row open at a time.
76
+ const [editingId, setEditingId] = useState<string | 'new' | null>(null)
77
+ const [draftName, setDraftName] = useState('')
78
+ const [draftColor, setDraftColor] = useState(defaultColor)
79
+ const [draftTag, setDraftTag] = useState('')
80
+ // Whether the tag has been typed into directly this session — once true, editing the name stops
81
+ // overwriting it (see deriveTag's own doc). Starts true for an existing profile that already has
82
+ // a tag (don't clobber a deliberate choice just because the name changed), false otherwise — a
83
+ // brand new profile, or one that's never had a tag set at all.
84
+ const [tagManuallySet, setTagManuallySet] = useState(false)
85
+ const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
86
+
87
+ // Commits whichever row is currently open, using its own latest draft state, then closes it —
88
+ // always closes, even when there's nothing valid to save (an empty name just discards the draft
89
+ // rather than leaving the row stuck open). This is the only way a row ever closes: submitting the
90
+ // name field (see EditRow's own focus-chain wiring — tag submits to name, name submits this),
91
+ // switching to a different row (see startEdit, which calls this before opening a new one), or
92
+ // leaving the screen entirely (see the unmount effect below) — no explicit save/cancel buttons,
93
+ // just scoped to the whole row instead of one field, since there's more than one field to touch.
94
+ const commitEdit = useCallback(() => {
95
+ const name = draftName.trim()
96
+ if (editingId && name) {
97
+ const tagValid = isValidTag(draftTag)
98
+ // Surfaced here, not just the field's own live `error` outline — that outline disappears the
99
+ // moment this row closes, so without this a still-invalid tag would just silently vanish
100
+ // (saved as empty) with no explanation of why it didn't stick.
101
+ if (draftTag && !tagValid) showErrorToast('Invalid tag', 'Use up to 3 letters or a single emoji')
102
+ const tag = tagValid ? draftTag : ''
103
+ if (editingId === 'new') onCreate({ color: draftColor, name, tag })
104
+ else onSave(editingId, { color: draftColor, name, tag })
105
+ }
106
+ setEditingId(null)
107
+ }, [editingId, draftName, draftColor, draftTag, onCreate, onSave, showErrorToast])
108
+
109
+ // Flushes whatever's currently being edited if this whole screen goes away mid-edit — a hardware
110
+ // back gesture or swipe-back can bypass a host screen's own back button (which should already
111
+ // call commitEdit before navigating away) but still unmounts this component the same as any other
112
+ // exit does. Reads the always-current commitEdit through a ref rather than depending on it
113
+ // directly in the effect — one that re-ran (and re-cleaned-up) on every keystroke would commit on
114
+ // every keystroke too, not just on a genuine unmount.
115
+ const commitEditRef = useRef(commitEdit)
116
+ useEffect(() => {
117
+ commitEditRef.current = commitEdit
118
+ })
119
+ useEffect(() => () => commitEditRef.current(), [])
120
+
121
+ const startEdit = useCallback(
122
+ (target: 'new' | Profile) => {
123
+ commitEdit()
124
+ if (target === 'new') {
125
+ setDraftName('')
126
+ setDraftColor(defaultColor)
127
+ setDraftTag('')
128
+ setTagManuallySet(false)
129
+ } else {
130
+ setDraftName(target.name)
131
+ setDraftColor(target.color)
132
+ setDraftTag(target.tag)
133
+ setTagManuallySet(target.tag !== '')
134
+ }
135
+ setEditingId(target === 'new' ? 'new' : target.id)
136
+ },
137
+ [commitEdit, defaultColor]
138
+ )
139
+
140
+ const handleNameChange = useCallback(
141
+ (name: string) => {
142
+ setDraftName(name)
143
+ if (!tagManuallySet) setDraftTag(deriveTag(name))
144
+ },
145
+ [tagManuallySet]
146
+ )
147
+
148
+ const handleTagChange = useCallback((tag: string) => {
149
+ setDraftTag(tag)
150
+ setTagManuallySet(true)
151
+ }, [])
152
+
153
+ const confirmDeleteProfile = profiles.find((p) => p.id === confirmDeleteId) ?? null
154
+ const handleConfirmDelete = useCallback(() => {
155
+ if (confirmDeleteId) {
156
+ onDelete(confirmDeleteId)
157
+ if (editingId === confirmDeleteId) setEditingId(null)
158
+ }
159
+ setConfirmDeleteId(null)
160
+ }, [confirmDeleteId, editingId, onDelete])
161
+
162
+ // Alphabetical — this list has no other natural order (creation order isn't meaningful to look
163
+ // something up by), and it's the one that actually stays usable once there are more than a
164
+ // handful of profiles on one shared device.
165
+ const sortedProfiles = useMemo(() => [...profiles].sort((a, b) => a.name.localeCompare(b.name)), [profiles])
166
+ const rows: Row[] = [...sortedProfiles.map((profile): Row => ({ kind: 'profile', profile })), { kind: 'new' }]
167
+ const tagInvalid = draftTag.length > 0 && !isValidTag(draftTag)
168
+
169
+ return (
170
+ <>
171
+ <View style={[styles.header, { paddingTop: 8 + insets.top, paddingLeft: 8 + insets.left }]}>
172
+ {headerLeft}
173
+ <Text variant='displaySmall' style={[styles.title, { color: fg }]}>
174
+ Profiles
175
+ </Text>
176
+ </View>
177
+
178
+ {/* This screen has no keyboard-avoidance of its own on a screen this tall, so this is what
179
+ actually keeps a focused row's own fields scrollable into view above the keyboard instead of
180
+ stuck behind it. keyboardShouldPersistTaps='handled' on the ScrollView below is what lets
181
+ tapping a *different* row (e.g. a color swatch, or another profile entirely) register on the
182
+ first tap while a field elsewhere is still focused, instead of just dismissing the keyboard. */}
183
+ <KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : undefined} style={styles.keyboardAvoiding}>
184
+ <ScrollView style={styles.scrollView} contentContainerStyle={[styles.content, { paddingBottom: 32 + insets.bottom }]} showsVerticalScrollIndicator={false} keyboardShouldPersistTaps='handled'>
185
+ {rows.map((row) => {
186
+ if (row.kind === 'new') {
187
+ if (editingId !== 'new') {
188
+ return (
189
+ <TouchableRipple key='new' onPress={() => startEdit('new')} style={styles.row}>
190
+ <View style={styles.rowInner}>
191
+ <View style={[styles.rowChip, styles.newChip, { borderColor: fg }]}>
192
+ <Icon source='plus' size={20} color={fg} />
193
+ </View>
194
+ <Text variant='bodyLarge' style={{ color: fg }}>
195
+ New Profile
196
+ </Text>
197
+ </View>
198
+ </TouchableRipple>
199
+ )
200
+ }
201
+ return <EditRow key='new' draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} tagInvalid={tagInvalid} onSubmit={commitEdit} autoFocus fgMuted={fgMuted} host={host} dark={dark} />
202
+ }
203
+
204
+ const { profile } = row
205
+ if (editingId === profile.id) {
206
+ return <EditRow key={profile.id} draftName={draftName} onNameChange={handleNameChange} draftColor={draftColor} onColorChange={setDraftColor} draftTag={draftTag} onTagChange={handleTagChange} tagInvalid={tagInvalid} onSubmit={commitEdit} onDelete={() => setConfirmDeleteId(profile.id)} fgMuted={fgMuted} host={host} dark={dark} />
207
+ }
208
+
209
+ // No delete affordance at rest — tap the row to start editing, which is the one place
210
+ // delete shows (see EditRow): a deliberate extra step before a destructive action is
211
+ // even reachable, not just before it's confirmed.
212
+ return (
213
+ <TouchableRipple key={profile.id} onPress={() => startEdit(profile)} style={styles.row}>
214
+ <View style={styles.rowInner}>
215
+ <ProfileChip profile={profile} size={CHIP_SIZE} />
216
+ <Text variant='bodyLarge' style={[styles.rowName, { color: fg }]} numberOfLines={1}>
217
+ {profile.name}
218
+ </Text>
219
+ </View>
220
+ </TouchableRipple>
221
+ )
222
+ })}
223
+ </ScrollView>
224
+ </KeyboardAvoidingView>
225
+
226
+ {confirmDeleteProfile && (
227
+ <Portal>
228
+ <View style={styles.overlay}>
229
+ <View style={[styles.overlayCard, { backgroundColor: cardBg, borderColor: cardBorder }]}>
230
+ <Icon source='alert-outline' size={64} color={colors.danger} />
231
+ <Text variant='headlineLarge' style={[styles.overlayTitle, { color: colors.danger }]}>
232
+ Delete Profile?
233
+ </Text>
234
+ <Text variant='bodyLarge' style={[styles.overlayBody, { color: fg }]}>
235
+ This permanently deletes {confirmDeleteProfile.name}&rsquo;s profile. This cannot be undone.
236
+ </Text>
237
+ {/* Contained neutral, not buttonColor={colors.primary} — colors.primary is often a
238
+ live gameplay/seat color in host apps, so a contained Cancel in that color would just
239
+ be whatever's currently painted there, with no relation to "safe to tap". */}
240
+ <Button mode='contained' onPress={() => setConfirmDeleteId(null)} style={styles.overlayButton} buttonColor={cancelBg} textColor={cancelFg}>
241
+ Cancel
242
+ </Button>
243
+ {/* colors.danger/onDanger — auto-paper's actual semantic role for this, not a seat's
244
+ own live color by luck of the default palette. */}
245
+ <Button mode='contained' onPress={handleConfirmDelete} style={styles.overlayButton} buttonColor={colors.danger} textColor={colors.onDanger}>
246
+ Delete
247
+ </Button>
248
+ </View>
249
+ </View>
250
+ </Portal>
251
+ )}
252
+ </>
253
+ )
254
+ }
255
+
256
+ interface EditRowProps {
257
+ draftName: string
258
+ onNameChange: (name: string) => void
259
+ draftColor: string
260
+ onColorChange: (color: string) => void
261
+ draftTag: string
262
+ onTagChange: (tag: string) => void
263
+ tagInvalid: boolean
264
+ onSubmit: () => void
265
+ // Omitted for the 'new' row — nothing saved yet to delete.
266
+ onDelete?: () => void
267
+ autoFocus?: boolean
268
+ fgMuted: string
269
+ host: PopoverHost
270
+ dark: boolean
271
+ }
272
+
273
+ // One row, expanded: the color trigger (@tastic/hud's InlineColorPicker, which takes a size prop
274
+ // rather than a fixed one, so it can be clamped to CHIP_SIZE along with every other circle on this
275
+ // screen), a tag field clamped to that same CHIP_SIZE footprint, the name taking up whatever's
276
+ // left, and — only for an existing profile — a delete icon at the very end. No `tag` passed to the
277
+ // color trigger here — it keeps its plain palette icon regardless of the row's own tag field, since
278
+ // the tag is already right next to it in its own field, so repeating it on the color trigger too
279
+ // would be redundant rather than informative.
280
+ // Return-key chains tag -> name (see @rific/focus-chain) rather than each field submitting on its
281
+ // own — submitting the *name* field is what actually commits the row (see the last field's own
282
+ // onSubmitEditing override below), matching how a normal multi-field form reads: fill fields in
283
+ // order, the last one finishes it.
284
+ function EditRow({ draftName, onNameChange, draftColor, onColorChange, draftTag, onTagChange, tagInvalid, onSubmit, onDelete, autoFocus, fgMuted, host, dark }: EditRowProps) {
285
+ const register = useFocusChain()
286
+ const tag = register()
287
+ const name = register()
288
+
289
+ return (
290
+ <View style={styles.editRow}>
291
+ <InlineColorPicker id='color' host={host} value={draftColor} onChange={onColorChange} dark={dark} size={CHIP_SIZE} />
292
+ {/* selectTextOnFocus so tapping into an already-set tag selects it for wholesale replacement
293
+ — the far more common edit than inserting into the middle of a 1-3 character value. */}
294
+ <TextInput ref={tag.ref} {...tag.props} mode='outlined' dense value={draftTag} onChangeText={onTagChange} placeholder='Tag' autoCapitalize='characters' selectTextOnFocus error={tagInvalid} returnKeyType='next' style={styles.tagInput} contentStyle={styles.tagInputContent} />
295
+ {/* tag.props' own onSubmitEditing (focus the next field) applies to every field but this
296
+ one — as the chain's last field, submitting it should finish the row instead of trying to
297
+ focus a field that doesn't exist, so this overrides it with the real commit action. Spread
298
+ order matters: name.props first, the explicit prop after, so this wins. */}
299
+ <TextInput ref={name.ref} {...name.props} mode='outlined' dense value={draftName} onChangeText={onNameChange} maxLength={MAX_PROFILE_NAME_LENGTH} placeholder='Name' autoFocus={autoFocus} returnKeyType='done' style={styles.nameInput} onSubmitEditing={onSubmit} />
300
+ {onDelete && (
301
+ <TouchableRipple onPress={onDelete} style={styles.iconButton}>
302
+ <Icon source='trash-can-outline' size={18} color={fgMuted} />
303
+ </TouchableRipple>
304
+ )}
305
+ </View>
306
+ )
307
+ }
308
+
309
+ const styles = StyleSheet.create({
310
+ content: {
311
+ gap: 4,
312
+ paddingHorizontal: 20
313
+ },
314
+ editRow: {
315
+ alignItems: 'center',
316
+ flexDirection: 'row',
317
+ gap: 8,
318
+ paddingVertical: 6
319
+ },
320
+ header: {
321
+ alignItems: 'center',
322
+ flexDirection: 'row',
323
+ gap: 4,
324
+ paddingBottom: 8
325
+ },
326
+ iconButton: {
327
+ alignItems: 'center',
328
+ borderRadius: 8,
329
+ height: 36,
330
+ justifyContent: 'center',
331
+ width: 36
332
+ },
333
+ keyboardAvoiding: {
334
+ flex: 1
335
+ },
336
+ nameInput: {
337
+ flex: 1
338
+ },
339
+ newChip: {
340
+ backgroundColor: 'transparent',
341
+ borderStyle: 'dashed',
342
+ borderWidth: 2
343
+ },
344
+ overlay: {
345
+ alignItems: 'center',
346
+ backgroundColor: 'rgba(0,0,0,0.72)',
347
+ bottom: 0,
348
+ justifyContent: 'center',
349
+ left: 0,
350
+ position: 'absolute',
351
+ right: 0,
352
+ top: 0
353
+ },
354
+ overlayBody: { textAlign: 'center' },
355
+ overlayButton: { width: 160 },
356
+ overlayCard: {
357
+ alignItems: 'center',
358
+ borderRadius: 20,
359
+ borderWidth: 1,
360
+ gap: 16,
361
+ maxWidth: 360,
362
+ padding: 32
363
+ },
364
+ overlayTitle: { fontWeight: 'bold', marginBottom: -8 },
365
+ row: {
366
+ borderRadius: 8,
367
+ paddingVertical: 8
368
+ },
369
+ rowChip: {
370
+ alignItems: 'center',
371
+ borderRadius: CHIP_SIZE / 2,
372
+ height: CHIP_SIZE,
373
+ justifyContent: 'center',
374
+ width: CHIP_SIZE
375
+ },
376
+ rowInner: {
377
+ alignItems: 'center',
378
+ flexDirection: 'row',
379
+ gap: 12
380
+ },
381
+ rowName: {
382
+ flex: 1
383
+ },
384
+ scrollView: {
385
+ flex: 1
386
+ },
387
+ // Wide enough for MAX_TAG_LENGTH monospace characters (or one emoji) plus its own padding/border
388
+ // — matched to CHIP_SIZE (the same footprint every other circle on this screen uses), not a
389
+ // fraction of the row's width, since the name field next to it is what should actually get the
390
+ // row's remaining space.
391
+ tagInput: {
392
+ height: CHIP_SIZE,
393
+ width: CHIP_SIZE
394
+ },
395
+ tagInputContent: {
396
+ // react-native-paper's outlined TextInput sets its own native input's minWidth from the
397
+ // placeholder/label's measured layout width (sized for a real label, not this field's own
398
+ // 3-character placeholder) — left alone, that floor is wider than CHIP_SIZE, so the actual
399
+ // editable box silently overflows past this field's own visible outline and centers within
400
+ // that wider, partly invisible box instead. Zeroing it here lets the input size to CHIP_SIZE
401
+ // instead, which is what actually makes `textAlign: 'center'` below center against the
402
+ // visible box rather than an overflowing invisible one.
403
+ minWidth: 0,
404
+ // Same reasoning as minWidth above, for the horizontal direction: the outlined input's own
405
+ // default padding (16px a side) is sized for a normal-width field, and at CHIP_SIZE's 40px
406
+ // that leaves only 8px total for actual text — not enough to fit the "Tag" placeholder itself
407
+ // (3 characters), which is what was clipping it to just "T" once minWidth stopped letting the
408
+ // box silently overflow wider to compensate.
409
+ paddingHorizontal: 2,
410
+ textAlign: 'center',
411
+ textAlignVertical: 'center'
412
+ },
413
+ title: {
414
+ flexShrink: 1,
415
+ fontWeight: 'bold'
416
+ }
417
+ })
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { ProfileChip } from './ProfileChip'
2
+ export { ProfilePicker } from './ProfilePicker'
3
+ export { ProfilesManager, type ProfilesManagerProps } from './ProfilesManager'
4
+ export { isValidProfile, isValidTag, MAX_PROFILE_NAME_LENGTH, MAX_TAG_LENGTH } from './profilesValidation'
5
+ export type { Profile } from './types'
@@ -0,0 +1,42 @@
1
+ import { Profile } from './types'
2
+
3
+ export const MAX_PROFILE_NAME_LENGTH = 20
4
+ // In code points, not JS string length — a single emoji can span more than one UTF-16 code unit
5
+ // (surrogate pairs, variation selectors), which .length would overcount.
6
+ export const MAX_TAG_LENGTH = 3
7
+
8
+ function isValidHexColor(value: unknown): value is string {
9
+ return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
10
+ }
11
+
12
+ // Codepoint 0xFE0F is the variation selector that forces an emoji-style rendering of an otherwise
13
+ // dual-purpose glyph; 0x200D is the zero-width joiner that chains multiple pictographs into one
14
+ // compound emoji (a family, a flag, a skin-tone variant) — built from String.fromCodePoint rather
15
+ // than embedded directly in a regex literal so this file's own source has no invisible characters
16
+ // in it.
17
+ const VARIATION_SELECTOR = String.fromCodePoint(0xfe0f)
18
+ const ZERO_WIDTH_JOINER = String.fromCodePoint(0x200d)
19
+ // Matches one emoji as a single unit, including a ZWJ-joined compound — good enough for telling
20
+ // "the user typed an emoji" from "the user typed letters" without needing full Unicode grapheme
21
+ // segmentation (Intl.Segmenter's own Hermes support is inconsistent enough not to lean on here).
22
+ const EMOJI_PATTERN = new RegExp(`^\\p{Extended_Pictographic}${VARIATION_SELECTOR}?(${ZERO_WIDTH_JOINER}\\p{Extended_Pictographic}${VARIATION_SELECTOR}?)*$`, 'u')
23
+
24
+ // Empty is valid too — a profile can have no tag yet, same as it can go without ever entering an
25
+ // editor's own tag field at all; every place this renders falls back to a generic icon in that case
26
+ // (see ProfileChip).
27
+ export function isValidTag(value: unknown): value is string {
28
+ if (typeof value !== 'string') return false
29
+ if (value.length === 0) return true
30
+ if (EMOJI_PATTERN.test(value)) return true
31
+ return !/\p{Extended_Pictographic}/u.test(value) && Array.from(value).length <= MAX_TAG_LENGTH
32
+ }
33
+
34
+ // Validates only the fields this package's own `Profile` type declares — an app that extends
35
+ // Profile with its own fields (a control scheme, anything else) needs its own wrapper that runs
36
+ // this first and then checks its own additions, same as it extends the type itself. See this
37
+ // package's own README for the composition pattern.
38
+ export function isValidProfile(value: unknown): value is Profile {
39
+ if (!value || typeof value !== 'object') return false
40
+ const v = value as Partial<Profile>
41
+ return typeof v.id === 'string' && v.id.length > 0 && typeof v.name === 'string' && v.name.trim().length > 0 && v.name.length <= MAX_PROFILE_NAME_LENGTH && isValidHexColor(v.color) && isValidTag(v.tag) && typeof v.createdAt === 'number' && typeof v.updatedAt === 'number'
42
+ }
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ // A saved player identity: just enough for a picker/roster UI to show and let someone select "who
2
+ // am I this round" — deliberately nothing else. An app that needs more (a key/control scheme, a
3
+ // stats bucket, anything else keyed by profile id) extends this shape on its own, in its own
4
+ // storage layer, and structural typing carries it straight through every component here that only
5
+ // ever reads these five fields — no wrapper or adapter needed on the app's side.
6
+ export interface Profile {
7
+ id: string
8
+ name: string
9
+ color: string
10
+ // User-typed identity mark shown on a seat's own color trigger and in the profile picker/roster —
11
+ // one emoji, or up to MAX_TAG_LENGTH plain characters (see profilesValidation.ts's isValidTag).
12
+ // Can be empty (no tag yet), which just falls back to a generic icon wherever it's shown.
13
+ tag: string
14
+ createdAt: number
15
+ updatedAt: number
16
+ }