Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
- "Was working before" = base branch, not previous commit. Base branch is almost always `nojima/HOTPOT-next-670-clean-2` (not `master`). Always run `gh pr view --json baseRefName` to confirm before any `git diff` or `git log` comparison.
- Never use `npm`. Always `yarn`.
- Never silently drop features/behavior — ask first, present options.
- In tests/stories, use `testuser` / `testuser-mac` as placeholder usernames — never real usernames like `chrisnojima`.
- No DOM elements (`<div>`, `<span>`, etc.) in plain `.tsx` files — use `Kb.*`. Guard desktop-only DOM with `Styles.isMobile`.
- Temp files go in `/tmp/`.
- Remove unused code when editing: styles, imports, vars, params, dead helpers.
Expand Down
119 changes: 119 additions & 0 deletions shared/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import path from 'path'
import webpack from 'webpack'
import {createRequire} from 'module'
import {fileURLToPath} from 'url'
import type {StorybookConfig} from '@storybook/react-webpack5'

const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const nullModulePath = path.resolve(rootDir, 'null-module.js')
const ignoredModules = require('../ignored-modules') as Array<string>

const makeAliases = (): Record<string, string | false> => {
// Sort longest-first: webpack checks in insertion order; longer prefixes must come first
// so subpath entries (e.g. 'foo/bar') are matched before their parent package ('foo').
const sortedModules = [...ignoredModules].sort((a, b) => b.length - a.length)
const alias = sortedModules.reduce<Record<string, string | false>>((acc, name) => {
acc[name] = nullModulePath
return acc
}, {})
return {
...alias,
'react-native$': 'react-native-web',
'react-native-reanimated': false,
'react-native/Libraries/Image/resolveAssetSource': nullModulePath,
'react-native-safe-area-context': path.resolve(rootDir, 'desktop/stubs/react-native-safe-area-context.js'),
'@react-native-picker/picker': path.resolve(rootDir, 'desktop/stubs/react-native-picker.js'),
// electron stub MUST come before '@' (insertion order matters for webpack alias matching)
'@/util/electron$': path.resolve(__dirname, 'mocks/electron.ts'),
'@': rootDir,
}
}

const config: StorybookConfig = {
stories: ['../**/*.stories.tsx'],
addons: [],
framework: {
name: '@storybook/react-webpack5',
options: {
builder: {
lazyCompilation: false,
},
},
},
staticDirs: [
{from: '../fonts/electron', to: '/fonts/electron'},
{from: '../images', to: '/images'},
],
typescript: {
check: false,
reactDocgen: false,
},
webpackFinal: webpackConfig => {
// Aliases + extensions (.tsx/.ts must be listed so webpack resolves index files and bare paths)
webpackConfig.resolve = webpackConfig.resolve ?? {}
webpackConfig.resolve.alias = {
...(webpackConfig.resolve.alias ?? {}),
...makeAliases(),
}
webpackConfig.resolve.extensions = ['.desktop.tsx', '.desktop.ts', '.tsx', '.ts', '.js', '.jsx', '.json']

// Storybook 10 does not include a JS/TS transpiler by default — add babel-loader
// so that TypeScript and JSX in story files and preview config are compiled.
// We provide explicit presets rather than relying on babel.config.js caller detection
// (the project config only enables @babel/preset-react for test env, not webpack).
webpackConfig.module = webpackConfig.module ?? {rules: []}
webpackConfig.module.rules = webpackConfig.module.rules ?? []
webpackConfig.module.rules.push({
test: /\.(tsx?|jsx?)$/,
exclude: /node_modules/,
use: [
{
loader: require.resolve('babel-loader'),
options: {
presets: [
['@babel/preset-env', {targets: {browsers: 'last 2 Chrome versions'}}],
['@babel/preset-react', {runtime: 'automatic'}],
'@babel/preset-typescript',
],
// No module-resolver here — webpack handles '@' alias directly so
// the webpack alias overrides (e.g. @/util/electron → mock) apply correctly.
},
},
],
})

// Fonts as assets (mirrors desktop/webpack.config.mts)
webpackConfig.module.rules.push({
test: /\.ttf$/,
type: 'asset/resource',
})

// Null-load native-only files (must run before other loaders)
webpackConfig.module.rules.unshift({
test: /\.(native|ios|android)\.(ts|js)x?$/,
use: ['null-loader'],
})

// Platform globals — same as desktop/webpack.config.mts makeDefineValues
webpackConfig.plugins = webpackConfig.plugins ?? []
webpackConfig.plugins.push(
new webpack.DefinePlugin({
isMobile: JSON.stringify(false),
isElectron: JSON.stringify(true),
isAndroid: JSON.stringify(false),
isIOS: JSON.stringify(false),
__DEV__: JSON.stringify(true),
__HOT__: JSON.stringify(false),
__PROFILE__: JSON.stringify(false),
__VERSION__: JSON.stringify('storybook'),
__FILE_SUFFIX__: JSON.stringify(''),
})
)

return webpackConfig
},
}

export default config
43 changes: 43 additions & 0 deletions shared/.storybook/mocks/electron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type {KB2} from '../../util/electron'

const stub: KB2 = {
constants: {
assetRoot: '/',
configOverload: {},
dokanPath: '',
downloadFolder: '',
env: {
APPDATA: '',
HOME: '/tmp',
KEYBASE_AUTOSTART: '',
KEYBASE_CRASH_REPORT: '',
KEYBASE_DEVEL_USE_XDG: '',
KEYBASE_RESTORE_UI: '',
KEYBASE_RUN_MODE: 'prod',
KEYBASE_START_UI: '',
KEYBASE_XDG_OVERRIDE: '',
LANG: 'en_US.UTF-8',
LC_ALL: '',
LC_TIME: '',
LOCALAPPDATA: '',
XDG_CACHE_HOME: '',
XDG_CONFIG_HOME: '',
XDG_DATA_HOME: '',
XDG_DOWNLOAD_DIR: '',
XDG_RUNTIME_DIR: '',
},
helloDetails: {argv: [], clientType: 2 as const, desc: 'Main Renderer', pid: 0, version: ''},
isRenderer: true,
pathSep: '/' as const,
platform: 'darwin' as const,
startDarkMode: false,
windowsBinPath: '',
},
functions: {
mainWindowDispatch: () => {},
},
}

export default stub
export const injectPreload = () => {}
export const waitOnKB2Loaded = (cb: () => void) => cb()
46 changes: 46 additions & 0 deletions shared/.storybook/preview-head.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<style>
/* Reset */
html, body, div, span, h1, h2, h3, h4, h5, h6, p, a, em, img, b, u, i,
ol, ul, li, table, caption, tbody, tfoot, thead, tr, th, td, article,
aside, details, figure, figcaption, footer, header, menu, nav, section, summary {
margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline;
}
body { line-height: 1; }

/* KB icon font */
@font-face {
font-family: 'kb';
src: url('/fonts/electron/kb.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: block;
}

/* Keybase text font */
@font-face { font-family: 'Keybase'; font-weight: 500; src: url('/fonts/electron/keybase-medium.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Keybase'; font-weight: 500; font-style: italic; src: url('/fonts/electron/keybase-medium-italic.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Keybase'; font-weight: 600; src: url('/fonts/electron/keybase-semibold.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Keybase'; font-weight: 600; font-style: italic; src: url('/fonts/electron/keybase-semibold-italic.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Keybase'; font-weight: 700; src: url('/fonts/electron/keybase-bold.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Keybase'; font-weight: 700; font-style: italic; src: url('/fonts/electron/keybase-bold-italic.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Keybase'; font-weight: 800; src: url('/fonts/electron/keybase-extrabold.ttf') format('truetype'); font-display: block; }

/* Source Code Pro */
@font-face { font-family: 'Source Code Pro'; font-weight: 500; src: url('/fonts/electron/SourceCodePro-Medium.ttf') format('truetype'); font-display: block; }
@font-face { font-family: 'Source Code Pro'; font-weight: 600; src: url('/fonts/electron/SourceCodePro-Semibold.ttf') format('truetype'); font-display: block; }

:root {
--size-xxtiny: 2px; --size-xtiny: 4px; --size-tiny: 8px; --size-xsmall: 12px;
--size-small: 16px; --size-medium: 24px; --size-mediumLarge: 32px;
--size-large: 40px; --size-xlarge: 64px; --size-border-radius: 4px;
color-scheme: light dark;
}

html { box-sizing: border-box; cursor: default; user-select: none; height: 100%; width: 100%; }
*, *:before, *:after { box-sizing: inherit; }
body { height: 100%; width: 100%; background-color: light-dark(#ffffff, #191919); }

.clickable { cursor: pointer; }
.clickable-box2 { cursor: pointer; }
.selectable { cursor: text; user-select: text; }
</style>
76 changes: 76 additions & 0 deletions shared/.storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React from 'react'
import type {Preview} from '@storybook/react'
import type {KB2} from '../util/electron'
import {initDesktopStyles} from '@/styles'
import {useDarkModeState} from '@/stores/darkmode'

// Inject a minimal KB2 stub so util/electron.tsx's getStashed() doesn't throw.
// The real app sets this from the Electron preload script; storybook sets it here.
const kb2Stub: KB2 = {
constants: {
assetRoot: '/',
configOverload: {},
dokanPath: '',
downloadFolder: '',
env: {
APPDATA: '',
HOME: '/tmp',
KEYBASE_AUTOSTART: '',
KEYBASE_CRASH_REPORT: '',
KEYBASE_DEVEL_USE_XDG: '',
KEYBASE_RESTORE_UI: '',
KEYBASE_RUN_MODE: 'prod',
KEYBASE_START_UI: '',
KEYBASE_XDG_OVERRIDE: '',
LANG: 'en_US.UTF-8',
LC_ALL: '',
LC_TIME: '',
LOCALAPPDATA: '',
XDG_CACHE_HOME: '',
XDG_CONFIG_HOME: '',
XDG_DATA_HOME: '',
XDG_DOWNLOAD_DIR: '',
XDG_RUNTIME_DIR: '',
},
helloDetails: {argv: [], clientType: 2 as const, desc: 'Main Renderer', pid: 0, version: ''},
isRenderer: true,
pathSep: '/' as const,
platform: 'darwin' as const,
startDarkMode: false,
windowsBinPath: '',
},
functions: {
mainWindowDispatch: () => {},
},
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
;(globalThis as any)._fromPreload = kb2Stub

initDesktopStyles()

export const globalTypes = {
darkMode: {
defaultValue: false,
},
}

const preview: Preview = {
decorators: [
(Story, context) => {
const dark = !!context.globals['darkMode']
const target = dark ? 'alwaysDark' : 'alwaysLight'
// false = don't write to config (no RPC available in storybook)
if (useDarkModeState.getState().darkModePreference !== target) {
useDarkModeState.getState().dispatch.setDarkModePreference(target, false)
}
// Required for light-dark() CSS vars to resolve correctly
document.documentElement.style.colorScheme = dark ? 'dark' : 'light'
Comment thread
chrisnojima marked this conversation as resolved.
return React.createElement('div', {style: {background: 'var(--color-white)', padding: 16}}, React.createElement(Story))
},
],
parameters: {
layout: 'fullscreen',
},
}

export default preview
57 changes: 57 additions & 0 deletions shared/chat/avatars.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type {Meta, StoryObj} from '@storybook/react'
import {Avatars, TeamAvatar} from './avatars'

const meta: Meta<typeof Avatars> = {
component: Avatars,
title: 'Chat/Avatars',
}
export default meta
type Story = StoryObj<typeof Avatars>

export const SingleUser: Story = {
args: {participantOne: 'alice'},
}

export const TwoUsers: Story = {
args: {participantOne: 'alice', participantTwo: 'bob'},
}

export const Muted: Story = {
args: {participantOne: 'alice', participantTwo: 'bob', isMuted: true},
}

export const Selected: Story = {
args: {participantOne: 'alice', participantTwo: 'bob', isSelected: true},
}

export const Locked: Story = {
args: {participantOne: 'alice', participantTwo: 'bob', isLocked: true},
}

export const SmallSize: Story = {
args: {participantOne: 'alice', singleSize: 32},
}

export const LargeSize: Story = {
args: {participantOne: 'alice', singleSize: 96},
}

type TeamStory = StoryObj<typeof TeamAvatar>

export const Team: TeamStory = {
render: () => (
<TeamAvatar teamname="keybase" isMuted={false} isSelected={false} isHovered={false} />
),
}

export const TeamMuted: TeamStory = {
render: () => (
<TeamAvatar teamname="keybase" isMuted={true} isSelected={false} isHovered={false} />
),
}

export const TeamSelected: TeamStory = {
render: () => (
<TeamAvatar teamname="keybase" isMuted={false} isSelected={true} isHovered={false} />
),
}
11 changes: 11 additions & 0 deletions shared/chat/inbox/row/big-teams-label.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type {Meta, StoryObj} from '@storybook/react'
import {BigTeamsLabel} from './big-teams-label'

const meta: Meta<typeof BigTeamsLabel> = {
component: BigTeamsLabel,
title: 'Chat/BigTeamsLabel',
}
export default meta
type Story = StoryObj<typeof BigTeamsLabel>

export const Default: Story = {}
31 changes: 31 additions & 0 deletions shared/chat/inbox/row/teams-divider.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type {Meta, StoryObj} from '@storybook/react'
import TeamsDivider from './teams-divider'

const meta: Meta<typeof TeamsDivider> = {
component: TeamsDivider,
title: 'Chat/TeamsDivider',
args: {
toggle: () => {},
smallTeamsExpanded: false,
showButton: false,
hiddenCount: 0,
},
}
export default meta
type Story = StoryObj<typeof TeamsDivider>

export const NoBigTeams: Story = {
args: {showButton: false, hiddenCount: 0, smallTeamsExpanded: false},
}

export const WithButton: Story = {
args: {showButton: true, hiddenCount: 12, smallTeamsExpanded: false},
}

export const WithButtonAndBadge: Story = {
args: {showButton: true, hiddenCount: 5, smallTeamsExpanded: false, badgeCount: 3},
}

export const Expanded: Story = {
args: {showButton: true, hiddenCount: 8, smallTeamsExpanded: true},
}
Loading