diff --git a/plans/clickablebox3.md b/plans/clickablebox3.md new file mode 100644 index 000000000000..04ee699989ff --- /dev/null +++ b/plans/clickablebox3.md @@ -0,0 +1,67 @@ +# ClickableBox3 Migration Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace all `ClickableBox` and `ClickableBox2` usages with `ClickableBox3`, a single component that combines Box2's layout prop surface with CB2's click/press handling, eliminating the ubiquitous `` nesting pattern. + +**Architecture:** `ClickableBox3` is `Box2Props & {onClick?, onLongPress?, hitSlop?}` — `direction` is required (same as Box2). On desktop it calls `box2ClassNames()` (shared helper extracted from Box2) and adds `clickable-box2` for cursor. On mobile it calls `box2SharedProps()` and passes the result to `Pressable`. + +**Tech Stack:** React, React Native, TypeScript, existing Box2/CB2 internals in `common-adapters/` + +--- + +## Background: CB1 vs CB2 vs CB3 + +| | CB1 | CB2 | CB3 | +|---|---|---|---| +| Desktop | `
` + JS hover state, underlay overlay | `
` + `.clickable-box2` CSS cursor | `
` + Box2 CSS classes + `.clickable-box2` | +| Mobile | `TouchableOpacity` / `TouchableWithoutFeedback` | `Pressable` | `Pressable` + Box2 style computation | +| Layout | `display:flex, flexDirection:column` injected by default | none | required via `direction` prop (same as Box2) | +| Props | large (hoverColor, underlayColor, feedback, etc.) | minimal | Box2Props + onClick/onLongPress/hitSlop | + +--- + +## ✅ Done: ClickableBox3 implemented and devices/ migrated + +Committed in `3ac6c14b82`. Key points for future reference: +- `ClickableBox3Props = Box2Props & {onClick?, onLongPress?, hitSlop?}` — `direction` required +- `box2ClassNames()` extracted from Box2 and shared; `box2SharedProps` exported from `box.tsx` +- `devices/` pilot complete: 3 CB2 usages → CB3, inner Box2 wrappers eliminated, `mobileAddHeader` style simplified + +--- + +## Migration Checklist (all remaining files) + +Use `migrate-clickable-box` skill for each chunk. Run `yarn lint && yarn tsc` and commit after each directory. + +### Pilot +- [x] `shared/devices/` (6 total) + +### Round 1 — small +- [ ] `shared/git/` (3) +- [ ] `shared/incoming-share/` (2) +- [ ] `shared/signup/` (2) +- [ ] `shared/provision/` (4) +- [ ] `shared/people/` (2) +- [ ] `shared/settings/` (4) +- [ ] `shared/profile/` (10) + +### Round 2 — medium +- [ ] `shared/tracker/` (4) +- [ ] `shared/menubar/` (3) +- [ ] `shared/app/` (4) +- [ ] `shared/router-v2/` (9) +- [ ] `shared/teams/` (25+) +- [ ] `shared/team-building/` (9+) + +### Round 3 — large +- [ ] `shared/fs/` (10+) +- [ ] `shared/chat/` (60+) + +### Last — shared primitives +- [ ] `shared/common-adapters/` (26) + +### Completion criteria +- [ ] `grep -rn "ClickableBox[^3]" shared/ | grep -v "clickable-box\|Props\|import\|export"` → zero results +- [ ] `yarn lint && yarn tsc` — zero errors +- [ ] Remove `ClickableBox` default export, `ClickableBox2`, `Props`, `Props2` from `clickable-box.tsx` diff --git a/plans/todo.md b/plans/todo.md index e0e86dd2acf9..2376ce52ba86 100644 --- a/plans/todo.md +++ b/plans/todo.md @@ -1,5 +1,6 @@ go screen by screen and find cleanup legends to more desktop +move to clickablebox3 automated testing of all screens any leftover zustand store legend list for chat thread native diff --git a/shared/common-adapters/box.tsx b/shared/common-adapters/box.tsx index ce406bb7367d..64a7c9c8be10 100644 --- a/shared/common-adapters/box.tsx +++ b/shared/common-adapters/box.tsx @@ -1,6 +1,6 @@ import type * as React from 'react' import * as Styles from '@/styles' -import {View} from 'react-native' +import {Pressable, View} from 'react-native' import type {MeasureRef} from '@/common-adapters/measure-ref' import type {NativeSyntheticEvent} from 'react-native' import './box.css' @@ -64,7 +64,7 @@ const hgapEndStyles = new Map(marginKeys.map(gap => [gap, {paddingRight: Styles. const vgapEndStyles = new Map(marginKeys.map(gap => [gap, {paddingBottom: Styles.globalMargins[gap]}])) const paddingStyles = new Map(marginKeys.map(p => [p, {padding: Styles.globalMargins[p]}])) -const box2SharedProps = (p: Box2Props) => { +export const box2SharedProps = (p: Box2Props) => { const {direction, fullHeight, fullWidth, centerChildren, alignSelf, alignItems, noShrink} = p const {flex, justifyContent, overflow, padding, relative} = p const {collapsable = true, onLayout, pointerEvents, children, gap, gapStart, gapEnd} = p @@ -183,46 +183,53 @@ const box2SharedProps = (p: Box2Props) => { } } +// Shared className generator used by Box2 and ClickableBox3. +export const box2ClassNames = (p: Box2Props, extra?: string): string => { + const {direction, alignItems, alignSelf, gap, gapStart, gapEnd, justifyContent, overflow} = p + const {padding, centerChildren, flex, fullHeight, fullWidth, noShrink, pointerEvents, relative, tooltip, className} = p + const horizontal = direction === 'horizontal' || direction === 'horizontalReverse' + const reverse = direction === 'verticalReverse' || direction === 'horizontalReverse' + return Styles.classNames( + extra, + { + [`box2_alignItems_${alignItems ?? ''}`]: alignItems, + [`box2_alignSelf_${alignSelf ?? ''}`]: alignSelf, + [`box2_gapEnd_${gap ?? ''}`]: gapEnd, + [`box2_gapStart_${gap ?? ''}`]: gapStart, + [`box2_gap_${gap ?? ''}`]: gap, + [`box2_justifyContent_${justifyContent ?? ''}`]: justifyContent, + [`box2_overflow_${overflow ?? ''}`]: overflow, + [`box2_padding_${padding ?? ''}`]: padding, + box2_centered: !fullHeight && !fullWidth, + box2_centeredChildren: centerChildren, + box2_flex1: flex === 1, + box2_fullHeight: fullHeight, + box2_fullWidth: fullWidth, + box2_horizontal: horizontal, + box2_no_shrink: noShrink, + box2_pointerEvents_none: pointerEvents === 'none', + box2_relative: relative, + box2_reverse: reverse, + box2_vertical: !horizontal, + tooltip, + }, + className + ) +} + export const Box2 = (p: Box2Props & {ref?: React.Ref}) => { if (!isMobile) { - const {direction, fullHeight, fullWidth, centerChildren, alignSelf, alignItems, noShrink, ref} = p - const {flex, justifyContent, overflow, padding, relative} = p + const {ref} = p const {onMouseMove, onMouseDown, onMouseLeave, onMouseUp, onMouseOver, onCopyCapture, children, testID} = p - const {onContextMenu, gap, gapStart, gapEnd, pointerEvents, onDragLeave, onDragOver, onDrop} = p - const {style: _style, className: _className, title, tooltip} = p - const horizontal = direction === 'horizontal' || direction === 'horizontalReverse' - const reverse = direction === 'verticalReverse' || direction === 'horizontalReverse' + const {onContextMenu, flex, onDragLeave, onDragOver, onDrop} = p + const {style: _style, title, tooltip} = p const style = Styles.collapseStyles([ flex != null && flex !== 1 ? {flex} : undefined, _style, ]) as unknown as React.CSSProperties - const className = Styles.classNames( - { - [`box2_alignItems_${alignItems ?? ''}`]: alignItems, - [`box2_alignSelf_${alignSelf ?? ''}`]: alignSelf, - [`box2_gapEnd_${gap ?? ''}`]: gapEnd, - [`box2_gapStart_${gap ?? ''}`]: gapStart, - [`box2_gap_${gap ?? ''}`]: gap, - [`box2_justifyContent_${justifyContent ?? ''}`]: justifyContent, - [`box2_overflow_${overflow ?? ''}`]: overflow, - [`box2_padding_${padding ?? ''}`]: padding, - box2_centered: !fullHeight && !fullWidth, - box2_centeredChildren: centerChildren, - box2_flex1: flex === 1, - box2_fullHeight: fullHeight, - box2_fullWidth: fullWidth, - box2_horizontal: horizontal, - box2_no_shrink: noShrink, - box2_pointerEvents_none: pointerEvents === 'none', - box2_relative: relative, - box2_reverse: reverse, - box2_vertical: !horizontal, - tooltip, - }, - _className - ) + const className = box2ClassNames(p) return (
void + onLongPress?: () => void + hitSlop?: number +} + +export const ClickableBox3 = (p: ClickableBox3Props & {ref?: React.Ref}) => { + const {onClick, onLongPress, hitSlop, ref, ...box2p} = p + + if (!isMobile) { + const {children, style: _style, onMouseOver, testID, flex} = box2p + const cn = box2ClassNames(box2p, 'clickable-box2') + const s = Styles.collapseStyles([flex != null && flex !== 1 ? {flex} : undefined, _style]) as React.CSSProperties + return ( +
} + className={cn} + onClick={onClick} + onMouseOver={onMouseOver} + style={s} + data-testid={testID} + > + {children} +
+ ) + } + + const {style: s, children: c} = box2SharedProps(box2p) + return ( + } + onPress={onClick ? () => { onClick() } : undefined} + onLongPress={onLongPress} + style={s} + hitSlop={hitSlop} + testID={box2p.testID} + > + {c} + + ) +} diff --git a/shared/common-adapters/clickable-box.tsx b/shared/common-adapters/clickable-box.tsx index 6ab7519de263..e840acc95e05 100644 --- a/shared/common-adapters/clickable-box.tsx +++ b/shared/common-adapters/clickable-box.tsx @@ -236,3 +236,4 @@ export const ClickableBox2 = (p: Props2 & {ref?: React.Ref}) ) } + diff --git a/shared/common-adapters/index.tsx b/shared/common-adapters/index.tsx index be0f3d017d40..18a170de3d46 100644 --- a/shared/common-adapters/index.tsx +++ b/shared/common-adapters/index.tsx @@ -28,6 +28,8 @@ export {default as Checkbox} from './checkbox' export {default as CheckCircle} from './check-circle' export {default as ChoiceList} from './choice-list' export {default as ClickableBox, ClickableBox2} from './clickable-box' +export {ClickableBox3} from './box' +export type {ClickableBox3Props} from './box' export {default as ConfirmModal} from './confirm-modal' export {default as CopyText} from './copy-text' export {default as CopyableText} from './copyable-text' diff --git a/shared/devices/add-device.tsx b/shared/devices/add-device.tsx index 70b06e834012..82c007770a0f 100644 --- a/shared/devices/add-device.tsx +++ b/shared/devices/add-device.tsx @@ -3,6 +3,7 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import {useProvisionState} from '@/stores/provision' import * as T from '@/constants/types' +import {getDeviceIconType} from './device-icon' type OwnProps = { highlight?: Array<'computer' | 'phone' | 'paper key'> @@ -39,7 +40,7 @@ export default function AddDevice(ownProps: OwnProps) { const navigateAppend = C.Router2.navigateAppend - // don't allow mutliple clicks to add paper key + // don't allow multiple clicks to add paper key const canAddPaperKeyRef = React.useRef(true) const onAddPaperKey = () => { if (!canAddPaperKeyRef.current) return @@ -63,11 +64,9 @@ export default function AddDevice(ownProps: OwnProps) { gapStart={true} gapEnd={true} > - - - Protect your account by having more devices and paper keys. - - + + Protect your account by having more devices and paper keys. + void type: 'computer' | 'paper key' | 'phone' } const bigIcon = C.isLargeScreen && isMobile -const getIconType = (deviceType: DeviceOptionProps['type'], iconNumber?: number) => { - let iconType: string - const size = bigIcon ? 96 : 64 - switch (deviceType) { - case 'computer': - iconType = iconNumber ? `icon-computer-background-${iconNumber}-${size}` : `icon-computer-${size}` - break - case 'paper key': - iconType = `icon-paper-key-${size}` - break - case 'phone': - iconType = iconNumber ? `icon-phone-background-${iconNumber}-${size}` : `icon-phone-${size}` - break - } - if (Kb.isValidIconType(iconType)) { - return iconType - } - return bigIcon ? 'icon-computer-96' : 'icon-computer-64' -} +const deviceOptionTypeMap = { + computer: 'desktop', + 'paper key': 'backup', + phone: 'mobile', +} as const const DeviceOption = ({highlight, iconNumber, onClick, type}: DeviceOptionProps) => ( - - - - - {type === 'paper key' ? 'Create' : 'Add'} a {type === 'phone' ? 'phone or tablet' : type} - - - + + + + {type === 'paper key' ? 'Create' : 'Add'} a {type === 'phone' ? 'phone or tablet' : type} + + ) const styles = Kb.Styles.styleSheetCreate(() => ({ diff --git a/shared/devices/common.tsx b/shared/devices/common.tsx new file mode 100644 index 000000000000..b560e633c3fd --- /dev/null +++ b/shared/devices/common.tsx @@ -0,0 +1,32 @@ +import * as Kb from '@/common-adapters' +import * as T from '@/constants/types' + +export const HeaderTitle = ({activeCount, revokedCount}: {activeCount: number; revokedCount: number}) => ( + + Devices + + {activeCount} Active • {revokedCount} Revoked + + +) + +const headerStyles = Kb.Styles.styleSheetCreate(() => ({ + headerTitle: { + paddingBottom: Kb.Styles.globalMargins.xtiny, + paddingLeft: Kb.Styles.globalMargins.xsmall, + }, +})) + +export const rpcDeviceDetailToDevice = (d: T.RPCGen.DeviceDetail): T.Devices.Device => ({ + created: d.device.cTime, + currentDevice: d.currentDevice, + deviceID: T.Devices.stringToDeviceID(d.device.deviceID), + deviceNumberOfType: d.device.deviceNumberOfType, + lastUsed: d.device.lastUsedTime, + name: d.device.name, + provisionedAt: d.provisionedAt || undefined, + provisionerName: d.provisioner ? d.provisioner.name : undefined, + revokedAt: d.revokedAt || undefined, + revokedByName: d.revokedByDevice ? d.revokedByDevice.name : undefined, + type: T.Devices.stringToDeviceType(d.device.type), +}) diff --git a/shared/devices/device-icon.tsx b/shared/devices/device-icon.tsx index f4269c223645..0794c76308ea 100644 --- a/shared/devices/device-icon.tsx +++ b/shared/devices/device-icon.tsx @@ -7,26 +7,52 @@ export type Props = { size: 32 | 64 | 96 style?: Kb.Styles.StylesCrossPlatform } -const DeviceIcon = (props: Props) => { +export const getDeviceIconType = ( + type: T.Devices.DeviceType, + iconNumber: T.Devices.IconNumber, + size: 32 | 64 | 96, + current?: boolean +): Kb.IconType => { const defaultIcons = { - backup: `icon-paper-key-${props.size}`, - desktop: `icon-computer-${props.size}`, - mobile: `icon-phone-${props.size}`, + backup: `icon-paper-key-${size}`, + desktop: `icon-computer-${size}`, + mobile: `icon-phone-${size}`, } as const - - const {type, deviceNumberOfType} = props.device - const iconNumber = T.Devices.deviceNumberToIconNumber(deviceNumberOfType) - const badge = props.current ? 'success-' : '' - + const badge = current ? 'success-' : '' const maybeIcon = ( { - backup: `icon-paper-key-${props.size}`, - desktop: `icon-computer-${badge}background-${iconNumber}-${props.size}`, - mobile: `icon-phone-${badge}background-${iconNumber}-${props.size}`, + backup: `icon-paper-key-${size}`, + desktop: `icon-computer-${badge}background-${iconNumber}-${size}`, + mobile: `icon-phone-${badge}background-${iconNumber}-${size}`, } as const )[type] - const icon: Kb.IconType = Kb.isValidIconType(maybeIcon) ? maybeIcon : defaultIcons[type] + return Kb.isValidIconType(maybeIcon) ? maybeIcon : defaultIcons[type] +} - return +export const getDeviceRevokeIconType = ( + type: T.Devices.DeviceType, + iconNumber: T.Devices.IconNumber +): Kb.IconType => { + const size = isMobile ? 64 : 48 + const maybeIcon = ( + { + backup: `icon-paper-key-revoke-${size}`, + desktop: `icon-computer-revoke-background-${iconNumber}-${size}`, + mobile: `icon-phone-revoke-background-${iconNumber}-${size}`, + } as const + )[type] + const fallback = ({ + backup: `icon-paper-key-revoke-${size}`, + desktop: `icon-computer-revoke-${size}`, + mobile: `icon-phone-revoke-${size}`, + } as const)[type] + return Kb.isValidIconType(maybeIcon) ? maybeIcon : fallback } + +const DeviceIcon = ({current, device, size, style}: Props) => ( + +) export default DeviceIcon diff --git a/shared/devices/device-page.tsx b/shared/devices/device-page.tsx index ef6958450540..84f5835e46b5 100644 --- a/shared/devices/device-page.tsx +++ b/shared/devices/device-page.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {formatTimeForDeviceTimeline, formatTimeRelativeToNow} from '@/util/timestamp' +import {getDeviceIconType} from './device-icon' type OwnProps = {canRevoke: boolean; device: T.Devices.Device} @@ -24,7 +25,7 @@ const TimelineLabel = (p: { }) => { const {desc, subDesc, subDescIsName, spacerOnBottom} = p return ( - + {desc} {!!subDesc && subDescIsName && ( @@ -91,7 +92,6 @@ const Timeline = (p: {device: T.Devices.Device}) => { const DevicePage = (ownProps: OwnProps) => { const {canRevoke, device} = ownProps - const iconNumber = T.Devices.deviceNumberToIconNumber(device.deviceNumberOfType) const navigateAppend = C.Router2.navigateAppend const showRevokeDevicePage = () => { navigateAppend({name: 'deviceRevoke', params: {device}}) @@ -105,15 +105,6 @@ const DevicePage = (ownProps: OwnProps) => { const deviceType = device.type - const maybeIcon = ( - { - backup: 'icon-paper-key-96', - desktop: `icon-computer-background-${iconNumber}-96`, - mobile: `icon-phone-background-${iconNumber}-96`, - } as const - )[deviceType] - const icon = Kb.isValidIconType(maybeIcon) ? maybeIcon : 'icon-computer-96' - const revokeName = { backup: 'paper key', desktop: 'computer', @@ -136,7 +127,7 @@ const DevicePage = (ownProps: OwnProps) => { fullWidth={true} fullHeight={true} > - + {device.revokedAt ? null : ( ( +const renderTLFEntry = (index: number, tlf: string) => ( @@ -24,23 +26,14 @@ const EndangeredTLFList = (props: {endangeredTLFs: Array}) => { You may lose access to these folders forever: - + {props.endangeredTLFs.map((tlf, index) => renderTLFEntry(index, tlf))} ) } const ActionButtons = ({onCancel, onSubmit}: {onCancel: () => void; onSubmit: () => void}) => ( - + void; onSubmit: () ) -const getIcon = (deviceType: T.Devices.DeviceType, iconNumber: T.Devices.IconNumber) => { - let iconType: Kb.IconType - const size = isMobile ? 64 : 48 - switch (deviceType) { - case 'backup': - iconType = `icon-paper-key-revoke-${size}` - break - case 'mobile': - iconType = `icon-phone-revoke-background-${iconNumber}-${size}` - break - case 'desktop': - iconType = `icon-computer-revoke-background-${iconNumber}-${size}` - break - } - if (Kb.isValidIconType(iconType)) { - return iconType - } - return isMobile ? 'icon-computer-revoke-64' : 'icon-computer-revoke-48' -} - -const rpcDeviceToDevice = (d: T.RPCGen.DeviceDetail): T.Devices.Device => ({ - created: d.device.cTime, - currentDevice: d.currentDevice, - deviceID: T.Devices.stringToDeviceID(d.device.deviceID), - deviceNumberOfType: d.device.deviceNumberOfType, - lastUsed: d.device.lastUsedTime, - name: d.device.name, - provisionedAt: d.provisionedAt || undefined, - provisionerName: d.provisioner ? d.provisioner.name : undefined, - revokedAt: d.revokedAt || undefined, - revokedByName: d.revokedByDevice ? d.revokedByDevice.name : undefined, - type: T.Devices.stringToDeviceType(d.device.type), -}) - const loadEndangeredTLF = async (actingDevice: string, targetDevice: string) => { if (!actingDevice || !targetDevice) { return [] @@ -152,7 +111,7 @@ const DeviceRevoke = (ownProps: OwnProps) => { [undefined, C.waitingKeyDevices], results => { const hydratedDevice = results - ?.map(rpcDeviceToDevice) + ?.map(rpcDeviceDetailToDevice) .find(candidate => candidate.deviceID === selectedDeviceID) if (hydratedDevice) { setLoadedDevice(hydratedDevice) @@ -192,13 +151,7 @@ const DeviceRevoke = (ownProps: OwnProps) => { if (!device) { return ( - + ) @@ -214,10 +167,10 @@ const DeviceRevoke = (ownProps: OwnProps) => { fullWidth={true} gap="small" gapEnd={true} - style={styles.container} + padding="small" > { const styles = Kb.Styles.styleSheetCreate( () => ({ - container: {padding: Kb.Styles.globalMargins.small}, endangeredTLFContainer: Kb.Styles.platformStyles({ isElectron: {alignSelf: 'center'}, isMobile: {flexGrow: 1}, diff --git a/shared/devices/index.tsx b/shared/devices/index.tsx index 50465e372a51..fe98e95d96bc 100644 --- a/shared/devices/index.tsx +++ b/shared/devices/index.tsx @@ -2,14 +2,14 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as React from 'react' import * as TestIDs from '@/tests/e2e/shared/test-ids' -import DeviceRow, {NewContext} from './row' +import DeviceRow, {BadgedDeviceIDsContext} from './row' import partition from 'lodash/partition' import * as T from '@/constants/types' import {intersect} from '@/util/set' import {useLocalBadging} from '@/util/use-local-badging' import {useModalHeaderState} from '@/stores/modal-header' -import {HeaderTitle} from './nav-header' import {useTypedNavigation} from '@/util/typed-navigation' +import {rpcDeviceDetailToDevice, HeaderTitle} from './common' const sortDevices = (a: T.Devices.Device, b: T.Devices.Device) => { if (a.currentDevice) return -1 @@ -17,20 +17,6 @@ const sortDevices = (a: T.Devices.Device, b: T.Devices.Device) => { return a.name.localeCompare(b.name) } -const rpcDeviceToDevice = (d: T.RPCGen.DeviceDetail): T.Devices.Device => ({ - created: d.device.cTime, - currentDevice: d.currentDevice, - deviceID: T.Devices.stringToDeviceID(d.device.deviceID), - deviceNumberOfType: d.device.deviceNumberOfType, - lastUsed: d.device.lastUsedTime, - name: d.device.name, - provisionedAt: d.provisionedAt || undefined, - provisionerName: d.provisioner ? d.provisioner.name : undefined, - revokedAt: d.revokedAt || undefined, - revokedByName: d.revokedByDevice ? d.revokedByDevice.name : undefined, - type: T.Devices.stringToDeviceType(d.device.type), -}) - const deviceToItem = (device: T.Devices.Device, canRevoke: boolean) => ({ canRevoke, device, @@ -43,7 +29,7 @@ const splitAndSortDevices = (devices: ReadonlyArray) => const itemHeight = {height: 48, type: 'fixed'} as const function ReloadableDevices() { -const navigation = useTypedNavigation('devicesRoot') + const navigation = useTypedNavigation('devicesRoot') const [devices, setDevices] = React.useState>([]) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyDevices) const loadDevicesRPC = C.useRPC(T.RPCGen.deviceDeviceHistoryListRpcPromise) @@ -55,7 +41,7 @@ const navigation = useTypedNavigation('devicesRoot') loadDevicesRPC( [undefined, C.waitingKeyDevices], results => { - setDevices(results?.map(rpcDeviceToDevice) ?? []) + setDevices(results?.map(rpcDeviceDetailToDevice) ?? []) }, _ => {} ) @@ -63,7 +49,6 @@ const navigation = useTypedNavigation('devicesRoot') const navigateAppend = C.Router2.navigateAppend const onAddDevice = (highlight?: Array<'computer' | 'phone' | 'paper key'>) => { - // We don't have navigateAppend in upgraded routes navigateAppend({name: 'deviceAdd', params: {highlight}}) } const navigateUp = C.Router2.navigateUp @@ -132,24 +117,24 @@ const navigation = useTypedNavigation('devicesRoot') reloadOnMount={true} title="" > - + {isMobile ? ( - onAddDevice()} style={headerStyles.container}> + onAddDevice()} direction="horizontal" centerChildren={true} relative={true} style={styles.mobileAddHeader}> {waiting ? ( ) : null} - + ) : null} {showPaperKeyNudge ? onAddDevice(['paper key'])} /> : null} - + ) } @@ -162,49 +147,11 @@ type Item = const styles = Kb.Styles.styleSheetCreate( () => ({ - progressContainer: { - ...Kb.Styles.globalStyles.fillAbsolute, - }, - revokedNote: { - padding: Kb.Styles.globalMargins.medium, - width: '100%', + mobileAddHeader: { + height: isMobile ? 64 : 48, + ...Kb.Styles.paddingH(Kb.Styles.globalMargins.small), }, - }) as const -) - -const headerStyles = Kb.Styles.styleSheetCreate(() => ({ - container: { - ...Kb.Styles.globalStyles.flexBoxRow, - ...Kb.Styles.centered(), - height: isMobile ? 64 : 48, - ...Kb.Styles.paddingH(Kb.Styles.globalMargins.small), - position: 'relative', - }, -})) - -const PaperKeyNudge = ({onAddDevice}: {onAddDevice: () => void}) => ( - - - - - - Create a paper key - - A paper key can be used to access your account in case you lose all your devices. Keep one in a - safe place (like a wallet) to keep your data safe. - - - {!isMobile && Create a paper key} - - - -) -const paperKeyNudgeStyles = Kb.Styles.styleSheetCreate( - () => - ({ - border: Kb.Styles.platformStyles({ + paperKeyNudgeBorder: Kb.Styles.platformStyles({ common: { ...Kb.Styles.border(Kb.Styles.globalColors.black_05, 1, Kb.Styles.borderRadius), flex: 1, @@ -216,7 +163,7 @@ const paperKeyNudgeStyles = Kb.Styles.styleSheetCreate( ...Kb.Styles.padding(Kb.Styles.globalMargins.tiny, Kb.Styles.globalMargins.xsmall), }, }), - container: Kb.Styles.platformStyles({ + paperKeyNudgeContainer: Kb.Styles.platformStyles({ common: { padding: Kb.Styles.globalMargins.small, }, @@ -224,11 +171,36 @@ const paperKeyNudgeStyles = Kb.Styles.styleSheetCreate( padding: Kb.Styles.globalMargins.tiny, }, }), - desc: Kb.Styles.platformStyles({ + paperKeyNudgeDesc: Kb.Styles.platformStyles({ isElectron: { maxWidth: 450, }, }), + progressContainer: { + ...Kb.Styles.globalStyles.fillAbsolute, + }, + revokedNote: { + padding: Kb.Styles.globalMargins.medium, + width: '100%', + }, }) as const ) + +const PaperKeyNudge = ({onAddDevice}: {onAddDevice: () => void}) => ( + + + + + Create a paper key + + A paper key can be used to access your account in case you lose all your devices. Keep one in a + safe place (like a wallet) to keep your data safe. + + + {!isMobile && Create a paper key} + + +) export default ReloadableDevices diff --git a/shared/devices/nav-header.tsx b/shared/devices/nav-header.tsx deleted file mode 100644 index 6c6e415ee807..000000000000 --- a/shared/devices/nav-header.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import * as C from '@/constants' -import * as Kb from '@/common-adapters' - -export const HeaderTitle = ({activeCount, revokedCount}: {activeCount: number; revokedCount: number}) => { - return ( - - Devices - - {activeCount} Active • {revokedCount} Revoked - - - ) -} - -export const HeaderRightActions = () => { - const navigateAppend = C.Router2.navigateAppend - const onAdd = () => navigateAppend({name: 'deviceAdd', params: {}}) - return ( - - ) -} - -const styles = Kb.Styles.styleSheetCreate(() => ({ - addDeviceButton: Kb.Styles.platformStyles({ - common: { - alignSelf: 'flex-end', - marginBottom: 6, - marginRight: Kb.Styles.globalMargins.xsmall, - }, - isElectron: Kb.Styles.desktopStyles.windowDraggingClickable, - }), - headerTitle: { - paddingBottom: Kb.Styles.globalMargins.xtiny, - paddingLeft: Kb.Styles.globalMargins.xsmall, - }, -})) diff --git a/shared/devices/paper-key.tsx b/shared/devices/paper-key.tsx index 0d4987c87fc0..25ab8233a84a 100644 --- a/shared/devices/paper-key.tsx +++ b/shared/devices/paper-key.tsx @@ -30,15 +30,14 @@ const PaperKey = () => { const clearModals = C.Router2.clearModals return ( - - + Paper key generated! Here is your unique paper key, it will allow you to perform important Keybase tasks in the future. @@ -66,7 +65,6 @@ const PaperKey = () => { waitingKey={C.waitingKeyDevices} /> - ) } diff --git a/shared/devices/routes.tsx b/shared/devices/routes.tsx index c2294725dda1..210502cda237 100644 --- a/shared/devices/routes.tsx +++ b/shared/devices/routes.tsx @@ -3,9 +3,33 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import {HeaderLeftButton, type HeaderBackButtonProps} from '@/common-adapters/header-buttons' import {newRoutes as provisionNewRoutes} from '../provision/routes-sub' -import {HeaderTitle, HeaderRightActions} from './nav-header' import {useProvisionState} from '@/stores/provision' import {defineRouteMap} from '@/constants/types/router' +import {HeaderTitle} from './common' + +const HeaderRightActions = () => { + const navigateAppend = C.Router2.navigateAppend + const onAdd = () => navigateAppend({name: 'deviceAdd', params: {}}) + return ( + + ) +} + +const headerStyles = Kb.Styles.styleSheetCreate(() => ({ + addDeviceButton: Kb.Styles.platformStyles({ + common: { + alignSelf: 'flex-end', + marginBottom: 6, + marginRight: Kb.Styles.globalMargins.xsmall, + }, + isElectron: Kb.Styles.desktopStyles.windowDraggingClickable, + }), +})) const AddDeviceCancelButton = () => { const cancel = useProvisionState(s => s.dispatch.dynamic.cancel) diff --git a/shared/devices/row.tsx b/shared/devices/row.tsx index e20263545d4a..b60e2281137b 100644 --- a/shared/devices/row.tsx +++ b/shared/devices/row.tsx @@ -12,9 +12,9 @@ type OwnProps = { firstItem: boolean } -export const NewContext = React.createContext>(new Set()) +export const BadgedDeviceIDsContext = React.createContext>(new Set()) -function Container(ownProps: OwnProps) { +function DeviceRow(ownProps: OwnProps) { const {canRevoke, device, firstItem} = ownProps const {deviceID} = device const navigateAppend = C.Router2.navigateAppend @@ -22,42 +22,42 @@ function Container(ownProps: OwnProps) { navigateAppend({name: 'devicePage', params: {canRevoke, device}}) } - const isNew = React.useContext(NewContext).has(deviceID) + const isNew = React.useContext(BadgedDeviceIDsContext).has(deviceID) const {currentDevice, name, revokedAt, lastUsed} = device const isRevoked = !!device.revokedByName return ( - - } - body={ - - - - {name} {currentDevice && (Current device)} + + } + body={ + + + + {name} {currentDevice && (Current device)} + + {isNew && !currentDevice && ( + + )} + + + {isRevoked + ? `Revoked ${revokedAt ? formatTimeRelativeToNow(revokedAt) : 'device'}` + : `Last used ${formatTimeRelativeToNow(lastUsed)}`} - {isNew && !currentDevice && ( - - )} - - {isRevoked - ? `Revoked ${revokedAt ? formatTimeRelativeToNow(revokedAt) : 'device'}` - : `Last used ${formatTimeRelativeToNow(lastUsed)}`} - - - } - /> + } + /> ) } @@ -78,4 +78,4 @@ const styles = Kb.Styles.styleSheetCreate( }) as const ) -export default Container +export default DeviceRow diff --git a/skill/migrate-clickable-box/SKILL.md b/skill/migrate-clickable-box/SKILL.md new file mode 100644 index 000000000000..4f1925297054 --- /dev/null +++ b/skill/migrate-clickable-box/SKILL.md @@ -0,0 +1,177 @@ +--- +name: migrate-clickable-box +description: Use when migrating ClickableBox or ClickableBox2 usages to ClickableBox3 in a given directory or file. Classifies usages, handles complex prop cases, and applies the migration safely. +--- + +# Migrate ClickableBox / ClickableBox2 → ClickableBox3 + +See `plans/clickablebox3.md` for the full migration plan, directory checklist, and completion criteria. + +## What is ClickableBox3? + +`ClickableBox3` = `ClickableBox2` + all `Box2` layout props (direction optional). On desktop it renders a `
` with the Box2 CSS class system plus `clickable-box2` cursor. On mobile it uses `Pressable` + `box2SharedProps` for layout. + +Type: `Omit & {direction?: ..., onClick?, onLongPress?, onMouseOver?, hitSlop?}` + +When `direction` is omitted: no flex layout — behaves like CB2 (plain clickable wrapper). +When `direction` is provided: Box2 layout is applied — **eliminates the inner Box2**. + +## Process + +```dot +digraph migrate { + "Identify target directory/file" [shape=box]; + "Grep for CB1/CB2 usages" [shape=box]; + "Read each file in full" [shape=box]; + "Classify each usage" [shape=box]; + "Present classified list + proposed changes" [shape=box]; + "User approves?" [shape=diamond]; + "Apply swaps" [shape=box]; + "yarn lint && yarn tsc" [shape=box]; + "Update plans/clickablebox3.md checklist" [shape=box]; + + "Identify target directory/file" -> "Grep for CB1/CB2 usages"; + "Grep for CB1/CB2 usages" -> "Read each file in full"; + "Read each file in full" -> "Classify each usage"; + "Classify each usage" -> "Present classified list + proposed changes"; + "Present classified list + proposed changes" -> "User approves?"; + "User approves?" -> "Apply swaps" [label="yes"]; + "User approves?" -> "Present classified list + proposed changes" [label="revise"]; + "Apply swaps" -> "yarn lint && yarn tsc"; + "yarn lint && yarn tsc" -> "Update plans/clickablebox3.md checklist"; +} +``` + +## Step 1: Find Usages + +From `shared/`: +``` +grep -n "ClickableBox[^3]" /*.tsx +``` + +Read each matched file fully before classifying. + +## Step 2: Classify Each Usage + +### Pattern A — CB wraps an immediate Box2 (most common, biggest win) + +```tsx + + + ... + + +``` + +**Action:** Merge into one `ClickableBox3`. Move all Box2 props up, remove the Box2 wrapper. + +```tsx + + ... + +``` + +### Pattern B — CB with only click props, no style + +```tsx + + + +``` + +**Action:** Simple swap to CB3 with no layout props. + +```tsx + + + +``` + +### Pattern C — CB with style that encodes flex layout + +```tsx + +// where row = { ...Kb.Styles.globalStyles.flexBoxRow, alignItems: 'center', ... } +``` + +**Action:** Move flex properties to CB3 props; keep non-flex properties in the style object. + +```tsx + +// row simplifies to: { height: ..., padding: ... } — remove flexBoxRow, centered(), position:'relative' +``` + +See Style Cleanup section below. + +### Pattern D — CB with props not in CB3 (rare) + +These CB1 props have no CB3 equivalent: +- `hoverColor`, `underlayColor` → add `hover_background_color_*` CSS className to CB3 instead +- `feedback={false}` → drop (Pressable doesn't have this) +- `activeOpacity` → drop +- `onMouseEnter` / `onMouseLeave` → rare; use a wrapper div if truly needed +- `onPressIn` / `onPressOut` → not in CB3; leave as CB1 and note it +- `tooltip` → wrap with `` outside CB3 +- `onLongPress={(e) => ...}` → remove the `e` param (CB3 signature is `() => void`) + +## Step 3: Present Changes Before Touching Anything + +For each usage show: file, line, pattern (A/B/C/D), proposed change. +Flag any D cases and ask before acting. Wait for approval. + +## Step 4: Apply Swaps + +1. Replace `Kb.ClickableBox` / `Kb.ClickableBox2` → `Kb.ClickableBox3` +2. When merging an inner Box2 (Pattern A): move its props to CB3, delete the Box2 opening and closing tags +3. Simplify styles where flex props moved to CB3 props (Pattern C) +4. Remove now-unused imports if CB1/CB2 fully replaced in file + +## Step 5: Validate + +From `shared/`: +``` +yarn lint && yarn tsc +``` +Fix errors before reporting done. + +## Step 6: Update Checklist + +Mark the completed directory in `plans/clickablebox3.md`. + +--- + +## Style Cleanup (Pattern C) + +When CB3 takes over flex layout via props, remove these from the style object passed to CB3: + +### Desktop (Electron) — safe to remove when CB3 handles them via props + +| Style pattern | CB3 prop replacement | +|---|---| +| `...Kb.Styles.globalStyles.flexBoxRow` | `direction="horizontal"` | +| `...Kb.Styles.globalStyles.flexBoxColumn` | `direction="vertical"` | +| `...Kb.Styles.centered()` (`alignItems+justifyContent: center`) | `centerChildren={true}` | +| `position: 'relative'` | `relative={true}` | +| `alignItems: 'center'` | `alignItems="center"` | +| `width: '100%'` / `maxWidth: '100%'` | `fullWidth={true}` | +| `height: '100%'` | `fullHeight={true}` | +| `flex: 1` | `flex={1}` | + +Keep in the style object: fixed `height`, `width` values, `padding`, `margin`, `borderRadius`, `backgroundColor`, `border*`, and anything not covered by a CB3/Box2 prop. + +### Mobile (React Native) + +`flexBoxRow` / `flexBoxColumn` on RN expand to only `{flexDirection: 'row/column'}` — no `display:flex` (RN doesn't use it). RN flex layout is unchanged between CB2 and CB3 when direction is provided. + +CB3 on RN does NOT add `borderRadius: 3` (CB1's old default). Remove it from styles only if it was purely inherited from CB1, not a design intent. + +### Note: CB3 does NOT have a `direction` default + +When `direction` is omitted, CB3 is a block-level clickable wrapper (no flex). If you need flex, always pass `direction`. + +## What NOT to Do + +- Don't swap cases where `onPressIn`/`onPressOut` is required — leave as CB1, note it +- Don't add hover CSS classes for colors not already in the design system — ask first +- Don't change surrounding logic, navigation, or state — component swap only +- Don't remove CB1/CB2 exports until the full migration is complete diff --git a/skill/simplify-ui-section/SKILL.md b/skill/simplify-ui-section/SKILL.md new file mode 100644 index 000000000000..d85aa4a40238 --- /dev/null +++ b/skill/simplify-ui-section/SKILL.md @@ -0,0 +1,221 @@ +--- +name: simplify-ui-section +description: Use when asked to review, clean up, simplify, or restructure a directory or section of UI code — improving readability, organization, naming, and component design without changing behavior +--- + +# Simplify UI Section + +## Overview + +Good UI code is easy to read, has clear boundaries, and doesn't repeat itself. This skill is a structured approach to identifying and fixing structural problems in a group of related files. + +**Core principle:** Read everything first, analyze thoroughly, ask before touching. + +## Process + +```dot +digraph simplify { + "Read all files in scope" [shape=box]; + "Analyze across categories" [shape=box]; + "Ask clarifying questions" [shape=box]; + "Present findings by category" [shape=box]; + "Show flat numbered list of ALL changes" [shape=box]; + "Ask: proceed, skip, or adjust?" [shape=box]; + "User approves?" [shape=diamond]; + "Implement changes" [shape=box]; + + "Read all files in scope" -> "Analyze across categories"; + "Analyze across categories" -> "Ask clarifying questions"; + "Ask clarifying questions" -> "Present findings by category"; + "Present findings by category" -> "Show flat numbered list of ALL changes"; + "Show flat numbered list of ALL changes" -> "Ask: proceed, skip, or adjust?"; + "Ask: proceed, skip, or adjust?" -> "User approves?" ; + "User approves?" -> "Implement changes" [label="yes"]; + "User approves?" -> "Present findings by category" [label="revise"]; +} +``` + +## Step 1: Read Everything First + +Read **every file** in scope before forming opinions. Patterns only become visible across the full picture. + +## Step 2: Analyze Across These Categories + +### Duplications +- Same function/logic copy-pasted across files (especially data-transformation helpers, icon-resolution, RPC mapping) +- Same inline component repeated in multiple places +- Same style values repeated without a shared constant + +### File Organization +- Tiny files (< 50 lines) that only export to one or two consumers — candidate for folding +- Files too large for their single responsibility — candidate for splitting +- Import directions that feel backwards (e.g., a detail screen importing from a list screen) + +### Naming +- Generic component names (`Container`, `Wrapper`, `Inner`) — rename to what they actually are +- Exported symbols named differently from their file (`export default Container` from `row.tsx`) +- Context/hooks defined in a file that doesn't own them + +### Component Hierarchy +- Wrapper components that add no logic — eliminate the layer +- Components split across files for no structural reason — candidate for collocating +- Deeply nested JSX that could flatten via composition + +### Props and Styles +- Components with large prop lists where many props just pass through — consider composition or context +- Repeated style patterns across components that could become a shared style helper or `Kb.Styles` utility call +- Platform-conditional logic repeated in multiple components instead of being handled once +- Styles inlined at call sites instead of in the stylesheet +- Style properties that can be replaced with Box2 props (see **Box2 Props** section below) + +### Shared Helpers and Components +- Patterns used 2+ times that have no shared abstraction +- Icon resolution logic duplicated across screens +- Small display components (badges, labels, markers) defined inline multiple times + +## Box2 Props: Replace Style Properties + +`Kb.Box2` (and `Box2`) has first-class props for many layout properties. When a Box2's style object contains properties that have a prop equivalent, move them out of the style and into the prop. This shrinks stylesheets and makes intent more readable. + +### Pure structural replacements (no visual change — do freely) + +These are exact equivalents. Moving them from style to prop changes nothing visible. + +| Style property | Box2 prop | +|---|---| +| `alignItems: 'center'` | `alignItems="center"` | +| `alignItems: 'flex-start'` | `alignItems="flex-start"` | +| `alignItems: 'flex-end'` | `alignItems="flex-end"` | +| `alignItems: 'stretch'` | `alignItems="stretch"` | +| `alignSelf: 'center'` | `alignSelf="center"` | +| `alignSelf: 'flex-start'` | `alignSelf="flex-start"` | +| `alignSelf: 'flex-end'` | `alignSelf="flex-end"` | +| `alignSelf: 'stretch'` | `alignSelf="stretch"` | +| `justifyContent: 'center'` | `justifyContent="center"` | +| `justifyContent: 'space-between'` | `justifyContent="space-between"` | +| `justifyContent: 'flex-start'` | `justifyContent="flex-start"` | +| `justifyContent: 'flex-end'` | `justifyContent="flex-end"` | +| `justifyContent: 'space-around'` | `justifyContent="space-around"` | +| `justifyContent: 'space-evenly'` | `justifyContent="space-evenly"` | +| `alignItems: 'center', justifyContent: 'center'` | `centerChildren` | +| `width: '100%'` | `fullWidth` | +| `height: '100%'` | `fullHeight` | +| `flexShrink: 0` | `noShrink` | +| `flex: 1` | `flex={1}` | +| `overflow: 'hidden'` | `overflow="hidden"` | +| `position: 'relative'` | `relative` | +| `padding: Styles.globalMargins.small` | `padding="small"` (uniform padding only) | + +`padding` accepts any `globalMargins` key: `xxtiny` `xtiny` `tiny` `xsmall` `small` `medium` `mediumLarge` `large` `xlarge`. Only use when padding is uniform on all sides; don't use if sides differ. + +After moving props out, if the style object becomes empty (`style={{}}`), remove the style prop entirely. + +### Gap prop: replaces per-child margins (validate first) + +`gap="small"` on a `Box2` inserts space **between** children using CSS `columnGap`/`rowGap`. This replaces the pattern of putting `marginTop`/`marginLeft`/`marginBottom`/`marginRight` on each child. + +`gap` accepts any `globalMargins` key. The spacing value must match a globalMargins token; if the existing margin is a raw number, check against the table above (e.g., `4 → xtiny`, `8 → tiny`, `16 → small`). + +**This is a slight visual change:** gap does not add space before the first child or after the last, while per-child margins typically do (at least on one end). Use `gapStart` and/or `gapEnd` to restore leading/trailing padding if needed. + +```tsx +// Before +const styles = Kb.Styles.styleSheetCreate(() => ({ + child: {marginBottom: Kb.Styles.globalMargins.small}, +})) + + + + + +// After + + + + +``` + +**Always flag gap conversions in the proposed-changes list** and note the visual implication (edge spacing removed). The user decides whether to accept. Don't bundle them silently with structural changes. + +### When gap doesn't apply + +- Spacing between only *some* siblings (not all) — gap is all-or-nothing +- Children that individually need different margins from each other +- Raw pixel values that don't map to any globalMargins token +- Margins used to push a single element away from something unrelated to sibling spacing + +## Step 3: Ask Before Acting + +Before presenting findings, ask these if the answers aren't obvious from the code: + +1. **External consumers**: Are there files outside this directory importing these? (affects what can be renamed or removed) +2. **Off-limits files**: Any files that should not be changed? +3. **New files**: Is adding a new file for deduplication okay, or prefer keeping file count flat/lower? +4. **Priority**: Any specific problem the user wants addressed most? + +## Step 4: Present ALL Findings and Get Approval + +**Do not touch any file until the user has seen and approved the full list.** + +Group findings clearly by category. For each item include: +- What the problem is +- What the fix would be +- Any tradeoff or risk (e.g., circular import risk if moving a context) + +End with an explicit summary: a flat numbered list of every proposed change, then ask the user to confirm scope before proceeding. Example: + +> **Proposed changes (7 total):** +> 1. Rename `rpcDeviceToDevice` → `rpcDeviceDetailToDevice` in `rpc.tsx` and callers +> 2. Fold `rpc.tsx` into `index.tsx`; update `device-revoke.tsx` import +> 3. Refactor `getDeviceIconType` to take `(type, iconNumber, size, current?)` instead of full device +> 4. ... +> +> Shall I proceed with all of these, or adjust scope? + +Wait for the user's response. Do not begin any edits until they reply. + +## Step 5: Implement + +Make all approved changes. Remove unused imports, styles, and variables left behind. Run lint and tsc after. + +## The Hard Line: No Unilateral Visual Changes + +**This skill is structural by default. Zero UX or behavior changes without explicit user sign-off.** + +This means: +- No changes to user-visible text, labels, or copy +- No changes to interaction flows, navigation, or state logic +- No "small improvements" to UX while you're in there +- No refactoring component logic even if it looks equivalent + +**Visual changes require validation first.** Flag them clearly in the proposed-changes list with a note like "(slight visual: removes trailing gap between last child and container edge)". The user decides whether to accept. Wait for approval before implementing. + +The one named exception: `gap` conversions (replacing per-child margins with Box2 `gap`). These are small but real visual changes. Always list them separately in the proposal so the user can opt in or out per case. + +If a behavior change is required to fix an outright bug discovered during the review, raise it separately — do not bundle it with structural changes. + +## Shared Helpers: Use `common.tsx` + +When two or more files in the same feature folder need a shared helper (data-mapping, RPC conversion, icon resolution, etc.), consolidate into a `common.tsx` in that folder. + +**Do not put shared helpers in the feature's `index.tsx`** — sub-views importing from the feature index creates a backwards dependency direction that will cause circular import problems as the feature grows. + +``` +devices/ + common.tsx ← shared helpers (rpcDeviceDetailToDevice, etc.) + index.tsx ← imports from common.tsx + device-revoke.tsx ← imports from common.tsx + device-page.tsx + ... +``` + +This pattern generalises: use `common.tsx` as the name regardless of feature folder. Other typical names like `utils.tsx` or `helpers.tsx` are acceptable if a project already uses them consistently. + +## What NOT to Do + +- Don't propose changes that require understanding runtime behavior (don't guess at logic equivalence) +- Don't add new abstractions unless there are 2+ concrete uses already +- Don't move things that would create circular imports +- Don't rename exports that have external consumers without confirming first +- Don't collapse files that serve genuinely different concerns just because they're small +- Don't put shared helpers in `index.tsx` — sub-views importing from the feature index creates backwards dependencies