diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index fd4a17ebf2..32f28e2b25 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -50,6 +50,7 @@ "@toeverything/theme": "^0.7.20", "@vanilla-extract/css": "^1.13.0", "@vanilla-extract/dynamic": "^2.0.3", + "animejs": "^3.2.2", "async-call-rpc": "^6.3.1", "bytes": "^3.1.2", "clsx": "^2.0.0", @@ -93,6 +94,7 @@ "@svgr/webpack": "^8.1.0", "@swc/core": "^1.3.93", "@testing-library/react": "^14.0.0", + "@types/animejs": "^3", "@types/bytes": "^3.1.3", "@types/image-blob-reduce": "^4.1.3", "@types/lodash-es": "^4.17.9", diff --git a/packages/frontend/core/src/components/affine/onboarding/articles/index.tsx b/packages/frontend/core/src/components/affine/onboarding/articles/index.tsx new file mode 100644 index 0000000000..15038bff6b --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/articles/index.tsx @@ -0,0 +1,228 @@ +import { article, articleWrapper, text, title } from '../curve-paper/paper.css'; +import type { ArticleId, ArticleOption } from '../types'; + +const ids = ['0', '1', '2', '3', '4'] as Array; + +/** locate paper */ +const paperLocations = { + '0': { + x: 0, + y: 0, + }, + '1': { + x: -240, + y: -100, + }, + '2': { + x: 240, + y: -100, + }, + '3': { + x: -480, + y: 40, + }, + '4': { + x: 480, + y: 50, + }, +}; + +/** paper enter animation config */ +const paperEnterAnimationOriginal = { + '0': { + curveCenter: 4, + curve: 292, + delay: 800, + fromZ: 1230, + fromX: -76, + fromY: 100, + fromRotateX: 185, + fromRotateY: -166, + fromRotateZ: 252, + toZ: 0, + // toX: 12, + // toY: -30, + toRotateZ: 6, + duration: '2s', + easing: 'ease', + }, + '1': { + curveCenter: 4, + curve: 280, + delay: 0, + fromZ: 3697, + fromX: 25, + fromY: -93, + fromRotateX: 331, + fromRotateY: 360, + fromRotateZ: -257, + toZ: 0, + // toX: -18, + // toY: -28, + toRotateZ: -8, + duration: '2s', + easing: 'ease', + }, + '2': { + curveCenter: 3, + curve: 660, + delay: 1700, + fromZ: 57379, + fromX: 2, + fromY: -77, + fromRotateX: 0, + fromRotateY: 0, + fromRotateZ: 0, + toZ: 0, + // toX: -3, + // toY: -21, + toRotateZ: 2, + duration: '2s', + easing: 'ease', + }, + '3': { + curveCenter: 4, + curve: 260, + delay: 1500, + fromZ: 4303, + fromX: -37, + fromY: -100, + fromRotateX: 360, + fromRotateY: 360, + fromRotateZ: 8, + toZ: 0, + // toX: -30, + // toY: -9, + toRotateZ: 2, + duration: '2s', + easing: 'ease', + }, + '4': { + curveCenter: 3, + curve: 270, + delay: 1571, + fromZ: 1876, + fromX: 65, + fromY: 48, + fromRotateX: 101, + fromRotateY: 188, + fromRotateZ: -200, + toZ: 0, + // toX: 24, + // toY: -2, + toRotateZ: 8, + duration: '2s', + easing: 'ease', + }, +}; + +export type PaperEnterAnimation = (typeof paperEnterAnimationOriginal)[0]; +export const paperEnterAnimations = paperEnterAnimationOriginal as Record< + any, + PaperEnterAnimation +>; + +/** Brief content */ +const paperBriefs = { + '0': ( +
+
+

Breath of the Wild: Redefining Game Design

+

+ “With all the time you spend watching TV,” he tells me, “you could + have written a novel by now.” It’s hard to disagree with the sentiment + — writing a novel is undoubtedly a better use of time than watching TV + — but what about the hidden assumption? Such comments imply that time + is “fungible” — that time spent watching TV can just as easily be + spent writing a novel. And sadly, that’s just not the case. +

+
+
+ ), + '1': ( +
+
+

Learning with earning with retrieval practice

+

+ Are there any specific techniques to make the process of learning more + effective? +

+

+ Students often re-read, underline, or highlight materials, thinking + that it will help them learn better. But, the best method for really + turning information into long-term memory is to use what is called + ‘retrieval practice’. +

+
+
+ ), + '2': ( +
+
+

+ Local-first software +
+ You own your data, in spite of the cloud +

+

+ Cloud apps like Google Docs and Trello are popular because they enable + real-time collaboration with colleagues, and they make it easy for us + to access our work from all of our devices. However, by centralizing + data storage on servers, cloud apps also take away ownership and + agency from users. If a service shuts down, the software stops + functioning, and data created with that software is lost. +

+
+
+ ), + '3': ( +
+
+

More Is Different

+

+ Broken symmetry and the nature of the hierarchical structure of + science +

+

+ The reductionist hypothesis may still be a topic for controversy among + philosophers, but among the great majority of active scientists I + think it is accepted without questions. The workings of our minds and + bodies, and of all the animate or inanimate matter of which we have + any detailed knowledge, are assumed to be controlled by the same set + of fundamental laws, which except under certain extreme conditions we + feel we know pretty well. +

+
+
+ ), + '4': ( +
+
+

HOWTO: Be more productive

+

+ “With all the time you spend watching TV,” he tells me, “you could + have written a novel by now.” It’s hard to disagree with the sentiment + — writing a novel is undoubtedly a better use of time than watching TV + — but what about the hidden assumption? Such comments imply that time + is “fungible” — that time spent watching TV can just as easily be + spent writing a novel. And sadly, that’s just not the case. +

+
+
+ ), +}; + +export const articles: Record = ids.reduce( + (acc, id) => { + return { + ...acc, + [id]: { + id, + location: paperLocations[id], + enterOptions: paperEnterAnimations[id], + brief: paperBriefs[id], + } satisfies ArticleOption, + }; + }, + {} as Record +); diff --git a/packages/frontend/core/src/components/affine/onboarding/curve-paper/paper.css.ts b/packages/frontend/core/src/components/affine/onboarding/curve-paper/paper.css.ts new file mode 100644 index 0000000000..6a8d0b657c --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/curve-paper/paper.css.ts @@ -0,0 +1,94 @@ +import { createVar, style } from '@vanilla-extract/css'; + +import { onboardingVars } from '../style.css'; + +export const paperWidthVar = createVar(); +export const paperHeightVar = createVar(); + +export const paper = style({ + vars: { + [paperWidthVar]: onboardingVars.paper.w, + [paperHeightVar]: onboardingVars.paper.h, + }, + + width: paperWidthVar, + height: paperHeightVar, + position: 'relative', +}); + +export const segment = style({ + width: '100%', + height: '100%', + background: onboardingVars.paper.bg, + position: 'absolute', + top: `calc(var(--segments-up) / var(--segments) * 100%)`, + + selectors: { + ['&[data-root="true"]']: { + height: `calc(1 / var(--segments) * 100%)`, + }, + ['&[data-direction="up"]']: { + top: 'unset', + bottom: `100%`, + transformOrigin: 'bottom', + }, + ['&[data-direction="down"]']: { + top: `100%`, + transformOrigin: 'top', + }, + ['&[data-top="true"]']: { + borderTopLeftRadius: onboardingVars.paper.r, + borderTopRightRadius: onboardingVars.paper.r, + }, + ['&[data-bottom="true"]']: { + borderBottomLeftRadius: onboardingVars.paper.r, + borderBottomRightRadius: onboardingVars.paper.r, + }, + }, +}); + +export const contentWrapper = style({ + width: '100%', + height: '100%', + overflow: 'hidden', + position: 'absolute', +}); + +export const content = style({ + padding: '16px', + overflow: 'hidden', + fontFamily: 'var(--affine-font-family)', + + selectors: { + [`${contentWrapper} > &`]: { + position: 'absolute', + width: paperWidthVar, + height: paperHeightVar, + top: `calc((var(--index)) * -100%)`, + }, + }, +}); + +export const articleWrapper = style({ + width: '100%', + height: '100%', + overflow: 'hidden', +}); + +export const article = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + color: onboardingVars.paper.textColor, +}); + +export const title = style({ + fontSize: '18px', + fontWeight: 600, + lineHeight: '26px', +}); +export const text = style({ + fontSize: '14px', + fontWeight: 400, + lineHeight: '22px', +}); diff --git a/packages/frontend/core/src/components/affine/onboarding/curve-paper/paper.tsx b/packages/frontend/core/src/components/affine/onboarding/curve-paper/paper.tsx new file mode 100644 index 0000000000..a04718fbe1 --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/curve-paper/paper.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +import * as styles from './paper.css'; +import { Segments } from './segments'; + +export interface PaperProps { + segments: number; + centerIndex: number; + content: ReactNode; +} + +export const Paper = (props: PaperProps) => { + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/onboarding/curve-paper/segment.tsx b/packages/frontend/core/src/components/affine/onboarding/curve-paper/segment.tsx new file mode 100644 index 0000000000..e901f3e238 --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/curve-paper/segment.tsx @@ -0,0 +1,43 @@ +import type { PropsWithChildren, ReactNode } from 'react'; + +import * as styles from './paper.css'; + +export interface SegmentProps extends PropsWithChildren { + index: number; + level?: number; + direction?: 'up' | 'down'; + content: ReactNode; + + isTop?: boolean; + isBottom?: boolean; + + [key: string]: any; +} + +export function Segment({ + children, + index, + direction, + content, + level, + isTop, + isBottom, + ...attrs +}: SegmentProps) { + const style = { '--index': index } as React.CSSProperties; + return ( +
+
+
{content}
+
+ {children} +
+ ); +} diff --git a/packages/frontend/core/src/components/affine/onboarding/curve-paper/segments.tsx b/packages/frontend/core/src/components/affine/onboarding/curve-paper/segments.tsx new file mode 100644 index 0000000000..80da82289d --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/curve-paper/segments.tsx @@ -0,0 +1,70 @@ +import { type ReactNode } from 'react'; + +import { Segment } from './segment'; + +export interface SegmentsProps { + level?: number; + direction?: 'up' | 'down'; + index: number; + root?: boolean; + centerIndex: number; + segments: number; + content: ReactNode; +} + +export function Segments({ + level, + direction, + root, + index, + centerIndex, + segments, + content, +}: SegmentsProps) { + if (!level) return null; + + const inherits = { centerIndex, segments, content }; + + if (root) { + const up = centerIndex; + const down = segments - up - 1; + const vars = { + '--segments': segments, + '--segments-up': up, + '--segments-down': down, + }; + return ( + + + + + ); + } + + const children = + level === 1 ? null : ( + + ); + return ( + + {children} + + ); +} diff --git a/packages/frontend/core/src/components/affine/onboarding/onboarding.tsx b/packages/frontend/core/src/components/affine/onboarding/onboarding.tsx new file mode 100644 index 0000000000..8648116def --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/onboarding.tsx @@ -0,0 +1,44 @@ +import type { CSSProperties } from 'react'; + +import { articles } from './articles'; +import { PaperSteps } from './paper-steps'; +import * as styles from './style.css'; + +interface OnboardingProps { + onOpenApp?: () => void; +} + +export const Onboarding = (_: OnboardingProps) => { + return ( +
+
+ {Object.entries(articles).map(([id, article]) => { + const { enterOptions, location } = article; + const style = { + '--fromX': `${enterOptions.fromX}vw`, + '--fromY': `${enterOptions.fromY}vh`, + '--fromZ': `${enterOptions.fromZ}px`, + '--toZ': `${enterOptions.toZ}px`, + '--fromRotateX': `${enterOptions.fromRotateX}deg`, + '--fromRotateY': `${enterOptions.fromRotateY}deg`, + '--fromRotateZ': `${enterOptions.fromRotateZ}deg`, + '--toRotateZ': `${enterOptions.toRotateZ}deg`, + + '--delay': `${enterOptions.delay}ms`, + '--duration': enterOptions.duration, + '--easing': enterOptions.easing, + + '--offset-x': `${location.x || 0}px`, + '--offset-y': `${location.y || 0}px`, + } as CSSProperties; + + return ( +
+ +
+ ); + })} +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/onboarding/paper-steps.tsx b/packages/frontend/core/src/components/affine/onboarding/paper-steps.tsx new file mode 100644 index 0000000000..c9dcabc56c --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/paper-steps.tsx @@ -0,0 +1,18 @@ +import { useCallback } from 'react'; + +import { AnimateIn } from './steps/animate-in'; +import type { ArticleOption } from './types'; + +interface PaperStepsProps { + show?: boolean; + article: ArticleOption; +} + +export const PaperSteps = ({ show, article }: PaperStepsProps) => { + const onFinished = useCallback(() => { + console.log('onFinished'); + }, []); + + if (!show) return null; + return ; +}; diff --git a/packages/frontend/core/src/components/affine/onboarding/steps/animate-in.css.ts b/packages/frontend/core/src/components/affine/onboarding/steps/animate-in.css.ts new file mode 100644 index 0000000000..5fab26a4dd --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/steps/animate-in.css.ts @@ -0,0 +1,21 @@ +import { keyframes, style } from '@vanilla-extract/css'; + +import { paperLocation } from '../style.css'; + +const moveInAnim = keyframes({ + '0%': { + transform: `translateZ(var(--fromZ)) translateX(var(--fromX)) translateY(var(--fromY)) rotateX(var(--fromRotateX)) rotateY(var(--fromRotateY)) rotateZ(var(--fromRotateZ))`, + }, + '100%': { + transform: `translateZ(var(--toZ)) translateX(0) translateY(0) rotateX(0deg) rotateY(0deg) rotateZ(var(--toRotateZ))`, + }, +}); + +export const moveIn = style([ + paperLocation, + { + animation: `${moveInAnim} var(--duration) ease forwards`, + animationDelay: 'var(--delay)', + transform: 'translateY(100vh)', // hide on init + }, +]); diff --git a/packages/frontend/core/src/components/affine/onboarding/steps/animate-in.tsx b/packages/frontend/core/src/components/affine/onboarding/steps/animate-in.tsx new file mode 100644 index 0000000000..d323da3e59 --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/steps/animate-in.tsx @@ -0,0 +1,66 @@ +import anime from 'animejs'; +import { useEffect } from 'react'; + +import { Paper, type PaperProps } from '../curve-paper/paper'; +import * as paperStyles from '../curve-paper/paper.css'; +import type { ArticleOption } from '../types'; +import * as styles from './animate-in.css'; + +interface AnimateInProps { + paperProps?: PaperProps; + article: ArticleOption; + onFinished?: () => void; +} + +const easing = 'spring(5, 100, 10, 0)'; +const animeSync = (params: Parameters[0]) => { + return new Promise(resolve => { + anime({ ...params, complete: () => resolve(null) }); + }); +}; + +export const AnimateIn = ({ + article, + paperProps, + onFinished, +}: AnimateInProps) => { + const { id: _id, enterOptions, brief } = article; + const id = `onboardingMoveIn${_id}`; + const segments = 4; + + const rotateX = enterOptions.curve / segments; + + useEffect(() => { + Promise.all([ + animeSync({ + targets: `[data-id="${id}"] .${paperStyles.segment}[data-direction="up"]`, + rotateX: [-rotateX, 0], + easing, + delay: enterOptions.delay, + }), + animeSync({ + targets: `[data-id="${id}"] ${paperStyles.segment}[data-direction="down"]`, + rotateX: [rotateX, 0], + easing, + delay: enterOptions.delay, + }), + ]) + .then(() => { + onFinished?.(); + }) + .catch(console.error); + }, [enterOptions.delay, id, rotateX, onFinished]); + + const props = { + ...paperProps, + segments, + content: brief, + centerIndex: Math.min(segments - 1, Math.max(0, enterOptions.curveCenter)), + }; + + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/onboarding/style.css.ts b/packages/frontend/core/src/components/affine/onboarding/style.css.ts new file mode 100644 index 0000000000..dfc0cfbfb8 --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/style.css.ts @@ -0,0 +1,74 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +// in case that we need to support dark mode later +export const onboardingVars = { + window: { + bg: 'var(--affine-pure-white)', + shadow: 'var(--affine-shadow-1)', + transition: { + size: '0.3s ease', + }, + }, + paper: { + w: '230px', + h: '302px', + r: '8px', + bg: 'var(--affine-pure-white)', + // textColor: 'var(--affine-light-text-primary-color)', + textColor: '#121212', + }, + web: { + bg: '#fafafa', // TODO: use var + }, +}; + +export const perspective = style({ + perspective: '10000px', + transformStyle: 'preserve-3d', +}); + +export const onboarding = style([ + perspective, + { + width: '100vw', + height: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + + selectors: { + // hack background color for web + '&::after': { + content: '', + position: 'absolute', + inset: 0, + background: onboardingVars.web.bg, + transform: 'translateZ(-1000px) scale(2)', + }, + '&[data-is-desktop="true"]::after': { + content: 'unset', + }, + }, + }, +]); + +globalStyle(`${onboarding} *`, { + perspective: '10000px', + transformStyle: 'preserve-3d', +}); + +export const offsetOrigin = style({ + width: 0, + height: 0, + position: 'relative', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const paperLocation = style({ + position: 'absolute', + left: `calc(var(--offset-x) - ${onboardingVars.paper.w} / 2)`, + top: `calc(var(--offset-y) - ${onboardingVars.paper.h} / 2)`, +}); diff --git a/packages/frontend/core/src/components/affine/onboarding/types.ts b/packages/frontend/core/src/components/affine/onboarding/types.ts new file mode 100644 index 0000000000..500b2a4363 --- /dev/null +++ b/packages/frontend/core/src/components/affine/onboarding/types.ts @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; + +export type OnboardingStep = 'enter' | 'unfold' | 'mode-switch'; +export type ArticleId = '0' | '1' | '2' | '3' | '4'; + +/** + * Paper enter animation options + */ +export interface PaperEnterOptions { + // animation-curve + curveCenter: number; + curve: number; + + // animation-move + fromZ: number; + fromX: number; + fromY: number; + fromRotateX: number; + fromRotateY: number; + fromRotateZ: number; + toZ: number; + toRotateZ: number; + + // move-in animation config + duration: number | string; + delay: number; + easing: string; +} + +export interface ArticleOption { + /** article id */ + id: ArticleId; + + /** paper enter animation content */ + brief: ReactNode; + + /** paper enter animation configuration */ + enterOptions: PaperEnterOptions; + + /** Locate paper */ + location: { + /** offset X */ + x: number; + /** offset Y */ + y: number; + }; +} diff --git a/packages/frontend/core/src/pages/onboarding.tsx b/packages/frontend/core/src/pages/onboarding.tsx index ccdb8ce6f0..1cdb210079 100644 --- a/packages/frontend/core/src/pages/onboarding.tsx +++ b/packages/frontend/core/src/pages/onboarding.tsx @@ -1,6 +1,7 @@ -import { Button } from '@affine/component/ui/button'; +import { useCallback } from 'react'; import { redirect } from 'react-router-dom'; +import { Onboarding } from '../components/affine/onboarding/onboarding'; import { appConfigStorage, useAppConfigStorage, @@ -18,9 +19,9 @@ export const loader = () => { export const Component = () => { const { jumpToIndex } = useNavigateHelper(); - const [onBoarding, setOnboarding] = useAppConfigStorage('onBoarding'); + const [, setOnboarding] = useAppConfigStorage('onBoarding'); - const openApp = () => { + const openApp = useCallback(() => { if (environment.isDesktop) { window.apis.ui.handleOpenMainApp().catch(err => { console.log('failed to open main app', err); @@ -29,24 +30,7 @@ export const Component = () => { jumpToIndex(RouteLogic.REPLACE); setOnboarding(false); } - }; + }, [jumpToIndex, setOnboarding]); - return ( -
- - onboarding page, onboarding mode is {onBoarding ? 'on' : 'off'} - -
- ); + return ; }; diff --git a/packages/frontend/electron/src/main/onboarding.ts b/packages/frontend/electron/src/main/onboarding.ts index c7e1297453..e0ef3692e7 100644 --- a/packages/frontend/electron/src/main/onboarding.ts +++ b/packages/frontend/electron/src/main/onboarding.ts @@ -1,5 +1,5 @@ import { assert } from 'console'; -import { BrowserWindow } from 'electron'; +import { BrowserWindow, screen } from 'electron'; import { join } from 'path'; import { mainWindowOrigin } from './constants'; @@ -27,17 +27,22 @@ async function createOnboardingWindow(additionalArguments: string[]) { assert(helperExposedMeta, 'helperExposedMeta should be defined'); + // get user's screen size + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + const browserWindow = new BrowserWindow({ - width: 800, - height: 600, + width, + height, frame: false, show: false, closable: false, minimizable: false, maximizable: false, fullscreenable: false, - skipTaskbar: true, - // transparent: true, + // skipTaskbar: true, + transparent: true, + backgroundColor: '#00FFFFFF', + hasShadow: false, webPreferences: { webgl: true, preload: join(__dirname, './preload.js'), @@ -46,7 +51,10 @@ async function createOnboardingWindow(additionalArguments: string[]) { }); browserWindow.on('ready-to-show', () => { - browserWindow.show(); + // TODO: add a timeout to avoid flickering, window is ready, but dom is not ready + setTimeout(() => { + browserWindow.show(); + }, 300); }); await browserWindow.loadURL( diff --git a/yarn.lock b/yarn.lock index 1574306145..da84b19937 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,6 +379,7 @@ __metadata: "@swc/core": "npm:^1.3.93" "@testing-library/react": "npm:^14.0.0" "@toeverything/theme": "npm:^0.7.20" + "@types/animejs": "npm:^3" "@types/bytes": "npm:^3.1.3" "@types/image-blob-reduce": "npm:^4.1.3" "@types/lodash-es": "npm:^4.17.9" @@ -386,6 +387,7 @@ __metadata: "@types/webpack-env": "npm:^1.18.2" "@vanilla-extract/css": "npm:^1.13.0" "@vanilla-extract/dynamic": "npm:^2.0.3" + animejs: "npm:^3.2.2" async-call-rpc: "npm:^6.3.1" bytes: "npm:^3.1.2" clsx: "npm:^2.0.0" @@ -14324,6 +14326,13 @@ __metadata: languageName: unknown linkType: soft +"@types/animejs@npm:^3": + version: 3.1.12 + resolution: "@types/animejs@npm:3.1.12" + checksum: 8ea5d0440236b87042ad012c0bfd90a38cf38688b8b28ad750d21eb01a3943c9256f0ec79ea0b40aad6e500ee177ed43d5aeec8f875f904b61f195e5e305d2ce + languageName: node + linkType: hard + "@types/argparse@npm:1.0.38": version: 1.0.38 resolution: "@types/argparse@npm:1.0.38" @@ -16580,6 +16589,13 @@ __metadata: languageName: node linkType: hard +"animejs@npm:^3.2.2": + version: 3.2.2 + resolution: "animejs@npm:3.2.2" + checksum: 7abdb56f415c666ba02f4e64fdbb10d457fed7e3711b0f006f97e48e5650097013397d890e8ceb31e9e06b73bf6dfd9202309d0dae0fc0b5190aa7c4e0ab7054 + languageName: node + linkType: hard + "ansi-align@npm:^3.0.1": version: 3.0.1 resolution: "ansi-align@npm:3.0.1"