diff --git a/apps/expo/app/_layout.tsx b/apps/expo/app/_layout.tsx index 8beb12aa3..e4ba45445 100644 --- a/apps/expo/app/_layout.tsx +++ b/apps/expo/app/_layout.tsx @@ -13,8 +13,8 @@ SplashScreen.preventAutoHideAsync() export default function RootLayout() { const [loaded, error] = useFonts({ - Inter: require('@tamagui/font-inter/otf/Inter-Medium.otf'), - InterBold: require('@tamagui/font-inter/otf/Inter-Bold.otf'), + // Inter: require('@tamagui/font-inter/otf/Inter-Medium.otf'), + // InterBold: require('@tamagui/font-inter/otf/Inter-Bold.otf'), }) // Expo Router uses Error Boundaries to catch errors in the navigation tree. diff --git a/apps/expo/package.json b/apps/expo/package.json index b5102b254..083684a7e 100755 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -49,7 +49,7 @@ "devDependencies": { "@babel/core": "^7.23.2", "@expo/metro-config": "^0.10.7", - "@tamagui/babel-plugin": "1.75.9", + "@tamagui/babel-plugin": "1.88.18", "metro-minify-terser": "^0.80.0", "typescript": "^5.2.2" } diff --git a/apps/next/app/layout.tsx b/apps/next/app/layout.tsx index 2ada56661..c37cc01df 100644 --- a/apps/next/app/layout.tsx +++ b/apps/next/app/layout.tsx @@ -10,8 +10,8 @@ if (typeof requestAnimationFrame === 'undefined') { } } import '@tamagui/core/reset.css' -import '@tamagui/font-inter/css/400.css' -import '@tamagui/font-inter/css/700.css' +// import '@tamagui/font-inter/css/400.css' +// import '@tamagui/font-inter/css/700.css' import { Provider } from 'app/provider' import { StylesProvider } from './styles-provider' diff --git a/apps/next/next-env.d.ts b/apps/next/next-env.d.ts index fd36f9494..4f11a03dc 100644 --- a/apps/next/next-env.d.ts +++ b/apps/next/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/next/next.config.js b/apps/next/next.config.js index 711208efc..b40677d6d 100644 --- a/apps/next/next.config.js +++ b/apps/next/next.config.js @@ -35,12 +35,16 @@ const plugins = [ withPWA, withTamagui({ appDir: true, + emitSingleCSSFile: false, + doesMutateThemes: false, config: './tamagui.config.ts', components: ['tamagui', '@t4/ui'], importsWhitelist: ['constants.js', 'colors.js'], outputCSS: process.env.NODE_ENV === 'production' ? './public/tamagui.css' : null, logTimings: true, disableExtraction, + enableCSSOptimizations: true, + excludeReactNativeWebExports: ['Switch', 'ProgressBar', 'Picker', 'CheckBox', 'Touchable'], shouldExtract: (path) => { if (path.includes(join('packages', 'app'))) { return true diff --git a/apps/next/package.json b/apps/next/package.json index d7296dd03..1c035e788 100644 --- a/apps/next/package.json +++ b/apps/next/package.json @@ -14,10 +14,11 @@ "dependencies": { "@cloudflare/next-on-pages": "1.8.3", "@ducanh2912/next-pwa": "^9.7.2", + "@fullhuman/postcss-purgecss": "^5.0.0", "@supabase/auth-helpers-nextjs": "^0.7.4", "@supabase/auth-helpers-react": "^0.4.2", "@t4/ui": "workspace:*", - "@tamagui/next-theme": "1.75.9", + "@tamagui/next-theme": "1.88.18", "@tsndr/cloudflare-worker-jwt": "2.2.7", "app": "workspace:*", "million": "2.6.4", @@ -25,14 +26,17 @@ "next-seo": "^6.4.0", "next-superjson-plugin": "^0.5.10", "pattycake": "^0.0.2", + "postcss": "^8.4.33", + "postcss-flexbugs-fixes": "^5.0.2", + "postcss-preset-env": "^9.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-native": "^0.72.6", - "react-native-web-lite": "1.75.9", + "react-native-web": "^0.19.10", "webpack": "^5.88.2" }, "devDependencies": { - "@tamagui/next-plugin": "1.75.9", + "@tamagui/next-plugin": "1.88.18", "@types/react": "^18.2.37", "vercel": "33.0.2", "wrangler": "3.22.3" diff --git a/apps/next/pages/ssr/index.tsx b/apps/next/pages/ssr/index.tsx deleted file mode 100644 index 815f2dafb..000000000 --- a/apps/next/pages/ssr/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Paragraph, YStack } from '@t4/ui' -import { GetServerSideProps } from 'next' - -export const runtime = 'experimental-edge' - -export const getServerSideProps = (async () => { - return { props: { content: 'This content is sent from the server' } } -}) satisfies GetServerSideProps<{ content: string }> - -export default function Page(props: { content: string }) { - return ( - - Server-side rendering - {props.content} - - ) -} diff --git a/apps/next/postcss.config.js b/apps/next/postcss.config.js new file mode 100644 index 000000000..acd8a906d --- /dev/null +++ b/apps/next/postcss.config.js @@ -0,0 +1,29 @@ +module.exports = { + plugins: [ + 'postcss-flexbugs-fixes', + [ + 'postcss-preset-env', + { + autoprefixer: { + flexbox: 'no-2009', + }, + stage: 3, + features: { + 'custom-properties': false, + }, + }, + ], + // [ + // '@fullhuman/postcss-purgecss', + // { + // content: [ + // './pages/**/*.{js,jsx,ts,tsx}', + // './../../packages/app/**/*.{js,jsx,ts,tsx}', + // './../../packages/ui/**/*.{js,jsx,ts,tsx}', + // ], + // defaultExtractor: (content) => content.match(/[\w-/:]+(? Math.round(size * 1.1), +// sizeLineHeight: (size) => Math.round(size * 1.1 + (size > 20 ? 10 : 10)), +// } +// ) + +const webFontFamily = + 'system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"' + +const headingFont = createFont({ + family: isWeb ? webFontFamily : 'System', size: { - 6: 15, + 2: 12, + 3: 14, + 4: 16, + 5: 18, + 6: 20, + 7: 24, + 8: 28, + 9: 32, + 10: 48, + }, + lineHeight: { + 2: 14, + 3: 16, + 4: 18, + 5: 20, + 6: 24, + 7: 28, + 8: 32, + 9: 40, + 10: 48, }, transform: { 6: 'uppercase', @@ -23,32 +92,59 @@ const headingFont = createInterFont({ 7: '$color', }, letterSpacing: { - 5: 2, - 6: 1, - 7: 0, - 8: -1, - 9: -2, - 10: -3, + 5: 3, + 6: 2, + 7: 1, + 8: 0, + 9: -1, + 10: -2, 12: -4, - 14: -5, - 15: -6, - }, - face: { - 700: { normal: 'InterBold' }, + 14: -6, + 15: -7, }, + // face: { + // 700: { normal: 'InterBold' }, + // }, }) -const bodyFont = createInterFont( - { - face: { - 700: { normal: 'InterBold' }, - }, +const bodyFont = createFont({ + family: isWeb ? webFontFamily : 'System', + size: { + // 2: 12, + // 3: 14, + // 4: 16, + // 5: 18, + // 7: 22, + // 8: 26, + // 9: 32, + // 10: 38, + 1: 12, + 2: 14, + 3: 12, + 4: 15, + 5: 24, + 7: 30, + 8: 36, + 9: 40, + 10: 52, + }, + letterSpacing: {}, + weight: { + 1: '300', + 4: '400', }, - { - sizeSize: (size) => Math.round(size * 1.1), - sizeLineHeight: (size) => Math.round(size * 1.1 + (size > 20 ? 10 : 10)), - } -) + lineHeight: { + 1: 14, + 2: 21, + 3: 24, + 4: 27, + 5: 36, + 7: 45, + 8: 54, + 9: 60, + 10: 78, + }, +}) export const config = createTamagui({ defaultFont: 'body', diff --git a/packages/ui/src/themes.ts b/packages/ui/src/themes.ts new file mode 100644 index 000000000..d9eee681e --- /dev/null +++ b/packages/ui/src/themes.ts @@ -0,0 +1,663 @@ +import { createThemeBuilder, MaskOptions } from '@tamagui/theme-builder' +import { masks } from '@tamagui/themes/v2-themes' +import { + blue, + blueDark, + gray, + grayDark, + green, + greenDark, + orange, + orangeDark, + pink, + pinkDark, + purple, + purpleDark, + red, + redDark, + yellow, + yellowDark, +} from '@tamagui/colors' +import { Variable, createTokens } from '@tamagui/web' + +const enabledThemes = { + blue: false, + gray: false, + green: false, + orange: false, + pink: false, + purple: false, + red: false, + yellow: false, +} + +const colorTokens = { + light: { + ...(enabledThemes.blue ? { blue } : {}), + ...(enabledThemes.gray ? { gray } : {}), + ...(enabledThemes.green ? { green } : {}), + ...(enabledThemes.orange ? { orange } : {}), + ...(enabledThemes.pink ? { pink } : {}), + ...(enabledThemes.purple ? { purple } : {}), + ...(enabledThemes.red ? { red } : {}), + ...(enabledThemes.yellow ? { yellow } : {}), + }, + dark: { + ...(enabledThemes.blue ? { blueDark } : {}), + ...(enabledThemes.gray ? { grayDark } : {}), + ...(enabledThemes.green ? { greenDark } : {}), + ...(enabledThemes.orange ? { orangeDark } : {}), + ...(enabledThemes.pink ? { pinkDark } : {}), + ...(enabledThemes.purple ? { purpleDark } : {}), + ...(enabledThemes.red ? { redDark } : {}), + ...(enabledThemes.yellow ? { yellowDark } : {}), + }, +} + +export const palettes = (() => { + const lightTransparent = 'rgba(255,255,255,0)' + const darkTransparent = 'rgba(10,10,10,0)' + + const transparent = (hsl: string, opacity = 0) => + hsl.replace(`%)`, `%, ${opacity})`).replace(`hsl(`, `hsla(`) + + const getColorPalette = (colors: Object, color = colors[0]): string[] => { + const colorPalette = Object.values(colors) + + // were re-ordering these + const [head, tail] = [colorPalette.slice(0, 6), colorPalette.slice(colorPalette.length - 5)] + + // add our transparent colors first/last + // and make sure the last (foreground) color is white/black rather than colorful + // this is mostly for consistency with the older theme-base + return [ + transparent(colorPalette[0]), + ...head, + ...tail, + color, + transparent(colorPalette[colorPalette.length - 1]), + ] + } + + const lightColor = 'hsl(0, 0%, 9.0%)' + const lightPalette = [ + lightTransparent, + '#fff', + '#f8f8f8', + 'hsl(0, 0%, 96.3%)', + 'hsl(0, 0%, 94.1%)', + 'hsl(0, 0%, 92.0%)', + 'hsl(0, 0%, 90.0%)', + 'hsl(0, 0%, 88.5%)', + 'hsl(0, 0%, 81.0%)', + 'hsl(0, 0%, 56.1%)', + 'hsl(0, 0%, 50.3%)', + 'hsl(0, 0%, 42.5%)', + lightColor, + darkTransparent, + ] + + const darkColor = '#fff' + const darkPalette = [ + darkTransparent, + '#050505', + '#151515', + '#191919', + '#232323', + '#282828', + '#323232', + '#424242', + '#494949', + '#545454', + '#626262', + '#a5a5a5', + darkColor, + lightTransparent, + ] + + const lightPalettes = objectFromEntries( + objectKeys(colorTokens.light).map( + (key) => [`light_${key}`, getColorPalette(colorTokens.light[key], lightColor)] as const + ) + ) + + const darkPalettes = objectFromEntries( + objectKeys(colorTokens.dark).map( + (key) => [`dark_${key}`, getColorPalette(colorTokens.dark[key], darkColor)] as const + ) + ) + + const colorPalettes = { + ...lightPalettes, + ...darkPalettes, + } + + return { + light: lightPalette, + dark: darkPalette, + ...colorPalettes, + } +})() + +const templateColorsSpecific = { + color1: 1, + color2: 2, + color3: 3, + color4: 4, + color5: 5, + color6: 6, + color7: 7, + color8: 8, + color9: 9, + color10: 10, + color11: 11, + color12: 12, +} + +export const templates = (() => { + // templates use the palette and specify index + // negative goes backwards from end so -1 is the last item + const template = { + ...templateColorsSpecific, + // the background, color, etc keys here work like generics - they make it so you + // can publish components for others to use without mandating a specific color scale + // the @tamagui/button Button component looks for `$background`, so you set the + // dark_red_Button theme to have a stronger background than the dark_red theme. + background: 2, + backgroundHover: 3, + backgroundPress: 4, + backgroundFocus: 5, + backgroundStrong: 1, + backgroundTransparent: 0, + color: -1, + colorHover: -2, + colorPress: -1, + colorFocus: -2, + colorTransparent: -0, + borderColor: 5, + borderColorHover: 6, + borderColorFocus: 4, + borderColorPress: 5, + placeholderColor: -4, + } + + return { + base: template, + colorLight: { + ...template, + // light color themes are a bit less sensitive + borderColor: 4, + borderColorHover: 5, + borderColorFocus: 4, + borderColorPress: 4, + }, + } +})() + +export const maskOptions = (() => { + const shadows = { + shadowColor: 0, + shadowColorHover: 0, + shadowColorPress: 0, + shadowColorFocus: 0, + } + + const colors = { + ...shadows, + color: 0, + colorHover: 0, + colorFocus: 0, + colorPress: 0, + } + + const baseMaskOptions: MaskOptions = { + override: shadows, + skip: shadows, + // avoids the transparent ends + max: palettes.light.length - 2, + min: 1, + } + + const skipShadowsAndSpecificColors = { + ...shadows, + ...templateColorsSpecific, + } + + return { + component: { + ...baseMaskOptions, + override: colors, + skip: skipShadowsAndSpecificColors, + }, + alt: { + ...baseMaskOptions, + }, + button: { + ...baseMaskOptions, + override: { + ...colors, + borderColor: 'transparent', + borderColorHover: 'transparent', + }, + skip: skipShadowsAndSpecificColors, + }, + } satisfies Record +})() + +const lightShadowColor = 'rgba(0,0,0,0.04)' +const lightShadowColorStrong = 'rgba(0,0,0,0.085)' +const darkShadowColor = 'rgba(0,0,0,0.2)' +const darkShadowColorStrong = 'rgba(0,0,0,0.3)' + +// should roughly map to button/input etc height at each level +// fonts should match that height/lineHeight at each stop +// so these are really non-linear on purpose +// why? +// - at sizes <1, used for fine grained things (borders, smallest paddingY) +// - so smallest padY should be roughly 1-4px so it can join with lineHeight +// - at sizes >=1, have to consider "pressability" (jumps up) +// - after that it should go upwards somewhat naturally +// - H1 / headings top out at 10 naturally, so after 10 we can go upwards faster +// but also one more wrinkle... +// space is used in conjunction with size +// i'm setting space to generally just a fixed fraction of size (~1/3-2/3 still fine tuning) +const size = { + $0: 0, + '$0.25': 2, + '$0.5': 4, + '$0.75': 8, + $1: 20, + '$1.5': 24, + $2: 28, + '$2.5': 32, + $3: 36, + '$3.5': 40, + $4: 44, + $true: 44, + '$4.5': 48, + $5: 52, + $6: 64, + $7: 74, + $8: 84, + $9: 94, + $10: 104, + $11: 124, + $12: 144, + $13: 164, + $14: 184, + $15: 204, + $16: 224, + $17: 224, + $18: 244, + $19: 264, + $20: 284, +} + +type SizeKeysIn = keyof typeof size +type Sizes = { + [Key in SizeKeysIn extends `$${infer Key}` ? Key : SizeKeysIn]: number +} +type SizeKeys = `${keyof Sizes extends `${infer K}` ? K : never}` + +const spaces = Object.entries(size).map(([k, v]) => { + return [k, sizeToSpace(v)] as const +}) + +const spacesNegative = spaces.slice(1).map(([k, v]) => [`-${k.slice(1)}`, -v]) + +type SizeKeysWithNegatives = + | Exclude<`-${SizeKeys extends `$${infer Key}` ? Key : SizeKeys}`, '-0'> + | SizeKeys + +const space: { + [Key in SizeKeysWithNegatives]: Key extends keyof Sizes ? Sizes[Key] : number +} = { + ...Object.fromEntries(spaces), + ...Object.fromEntries(spacesNegative), +} as any + +const zIndex = { + 0: 0, + 1: 100, + 2: 200, + 3: 300, + 4: 400, + 5: 500, +} + +const darkColors = { + ...(enabledThemes.blue ? colorTokens.dark.blue : {}), + ...(enabledThemes.gray ? colorTokens.dark.gray : {}), + ...(enabledThemes.green ? colorTokens.dark.green : {}), + ...(enabledThemes.orange ? colorTokens.dark.orange : {}), + ...(enabledThemes.pink ? colorTokens.dark.pink : {}), + ...(enabledThemes.purple ? colorTokens.dark.purple : {}), + ...(enabledThemes.red ? colorTokens.dark.red : {}), + ...(enabledThemes.yellow ? colorTokens.dark.yellow : {}), +} + +const lightColors = { + ...(enabledThemes.blue ? colorTokens.light.blue : {}), + ...(enabledThemes.gray ? colorTokens.light.gray : {}), + ...(enabledThemes.green ? colorTokens.light.green : {}), + ...(enabledThemes.orange ? colorTokens.light.orange : {}), + ...(enabledThemes.pink ? colorTokens.light.pink : {}), + ...(enabledThemes.purple ? colorTokens.light.purple : {}), + ...(enabledThemes.red ? colorTokens.light.red : {}), + ...(enabledThemes.yellow ? colorTokens.light.yellow : {}), +} + +const color = { + ...postfixObjKeys(lightColors, 'Light'), + ...postfixObjKeys(darkColors, 'Dark'), +} + +const radius = { + 0: 0, + 1: 3, + 2: 5, + 3: 7, + 4: 9, + true: 9, + 5: 10, + 6: 16, + 7: 19, + 8: 22, + 9: 26, + 10: 34, + 11: 42, + 12: 50, +} + +export const tokens = createTokens({ + color, + radius, + zIndex, + space, + size, +}) + +const shadows = { + light: { + shadowColor: lightShadowColorStrong, + shadowColorHover: lightShadowColorStrong, + shadowColorPress: lightShadowColor, + shadowColorFocus: lightShadowColor, + }, + dark: { + shadowColor: darkShadowColorStrong, + shadowColorHover: darkShadowColorStrong, + shadowColorPress: darkShadowColor, + shadowColorFocus: darkShadowColor, + }, +} + +const colorThemeDefinition = (colorName: string) => [ + { + parent: 'light', + palette: colorName, + template: 'colorLight', + }, + { + parent: 'dark', + palette: colorName, + template: 'base', + }, +] + +const nonInherited = { + light: { + ...lightColors, + ...shadows.light, + }, + dark: { + ...darkColors, + ...shadows.dark, + }, +} + +const overlayThemeDefinitions = [ + { + parent: 'light', + theme: { + background: 'rgba(0,0,0,0.5)', + }, + }, + { + parent: 'dark', + theme: { + background: 'rgba(0,0,0,0.9)', + }, + }, +] + +// --- themeBuilder --- + +const themeBuilder = createThemeBuilder() + .addPalettes(palettes) + .addTemplates(templates) + .addMasks(masks) + .addThemes({ + light: { + template: 'base', + palette: 'light', + nonInheritedValues: nonInherited.light, + }, + dark: { + template: 'base', + palette: 'dark', + nonInheritedValues: nonInherited.dark, + }, + }) + .addChildThemes({ + ...(enabledThemes.blue ? { blue: colorThemeDefinition('blue') } : {}), + ...(enabledThemes.gray ? { gray: colorThemeDefinition('gray') } : {}), + ...(enabledThemes.green ? { green: colorThemeDefinition('green') } : {}), + ...(enabledThemes.orange ? { orange: colorThemeDefinition('orange') } : {}), + ...(enabledThemes.pink ? { pink: colorThemeDefinition('pink') } : {}), + ...(enabledThemes.purple ? { purple: colorThemeDefinition('purple') } : {}), + ...(enabledThemes.red ? { red: colorThemeDefinition('red') } : {}), + ...(enabledThemes.yellow ? { yellow: colorThemeDefinition('yellow') } : {}), + }) + .addChildThemes({ + alt1: { + mask: 'soften', + ...maskOptions.alt, + }, + alt2: { + mask: 'soften2Border1', + ...maskOptions.alt, + }, + active: { + mask: 'soften3FlatBorder', + skip: { + color: 1, + }, + }, + }) + .addChildThemes( + { + ListItem: [ + { + parent: 'light', + avoidNestingWithin: ['active'], + mask: 'identity', + ...maskOptions.component, + }, + { + parent: 'dark', + avoidNestingWithin: ['active'], + mask: 'identity', + ...maskOptions.component, + }, + ], + + Card: { + mask: 'soften', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + Button: { + mask: 'soften2Border1', + ...maskOptions.component, + }, + + Checkbox: { + mask: 'softenBorder2', + ...maskOptions.component, + }, + + Switch: { + mask: 'soften2Border1', + ...maskOptions.component, + }, + + SwitchThumb: { + mask: 'inverseStrengthen2', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + TooltipContent: { + mask: 'soften2Border1', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + DrawerFrame: { + mask: 'soften', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + Progress: { + mask: 'soften', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + RadioGroupItem: { + mask: 'softenBorder2', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + TooltipArrow: { + mask: 'soften', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + SliderTrackActive: { + mask: 'inverseSoften', + ...maskOptions.component, + }, + + SliderTrack: { + mask: 'soften2Border1', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + SliderThumb: { + mask: 'inverse', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + Tooltip: { + mask: 'inverse', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + ProgressIndicator: { + mask: 'inverse', + avoidNestingWithin: ['active'], + ...maskOptions.component, + }, + + SheetOverlay: overlayThemeDefinitions, + DialogOverlay: overlayThemeDefinitions, + ModalOverlay: overlayThemeDefinitions, + + Input: { + mask: 'softenBorder2', + ...maskOptions.component, + }, + + TextArea: { + mask: 'softenBorder2', + ...maskOptions.component, + }, + }, + { + // to save bundle size but make alt themes not work on components + // avoidNestingWithin: ['alt1', 'alt2'], + } + ) + +// --- main export --- + +export const themes = themeBuilder.build() + +/** + * if typescript too deep types :/ + * + +const themesIn = themeBuilder.build() +type ThemesIn = typeof themesIn +type ThemesOut = Omit & { + light: ThemesIn['light'] & typeof nonInherited.light + dark: ThemesIn['dark'] & typeof nonInherited.dark +} +export const themes = themesIn as ThemesOut + + **/ + +// --- utils --- + +function postfixObjKeys | string }, B extends string>( + obj: A, + postfix: B +): { + [Key in `${keyof A extends string ? keyof A : never}${B}`]: Variable | string +} { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [`${k}${postfix}`, v])) as any +} + +// a bit odd but keeping backward compat for values >8 while fixing below +function sizeToSpace(v: number) { + if (v === 0) return 0 + if (v === 2) return 0.5 + if (v === 4) return 1 + if (v === 8) return 1.5 + if (v <= 16) return Math.round(v * 0.333) + return Math.floor(v * 0.7 - 12) +} + +function objectFromEntries(arr: ARR_T): EntriesToObject { + return Object.fromEntries(arr) as EntriesToObject +} + +type EntriesType = [PropertyKey, unknown][] | ReadonlyArray + +type DeepWritable = { -readonly [P in keyof OBJ_T]: DeepWritable } +type UnionToIntersection = // From https://stackoverflow.com/a/50375286 + (UNION_T extends any ? (k: UNION_T) => void : never) extends (k: infer I) => void ? I : never + +type UnionObjectFromArrayOfPairs = + DeepWritable extends (infer R)[] + ? R extends [infer key, infer val] + ? { [prop in key & PropertyKey]: val } + : never + : never +type MergeIntersectingObjects = { [key in keyof ObjT]: ObjT[key] } +type EntriesToObject = MergeIntersectingObjects< + UnionToIntersection> +> + +function objectKeys(obj: O) { + return Object.keys(obj) as Array +}