mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-12-29 11:52:34 +08:00
feat(tutorial): Overlay bubbles that guide you through initial features
Summary: Add header to show how many trial days remain More rendering out of Store, consolidate registry and store and expose via new serviceRegistry WIP Merge branch 'master' into hallamoore/feature-walkthrough-tutorial Switch to using observable instead of AbortablePromise Update submodule WIP WIP Remove annotations Remove changes WIP Test Plan: No tests Reviewers: evan, juan Reviewed By: juan Differential Revision: https://phab.nylas.com/D3260
This commit is contained in:
parent
78a999feb7
commit
7533c7ae81
17 changed files with 529 additions and 23 deletions
|
@ -1,19 +0,0 @@
|
|||
{ComponentRegistry, WorkspaceStore} = require 'nylas-exports'
|
||||
ModeToggle = require './mode-toggle'
|
||||
|
||||
# NOTE: this is a hack to allow ComponentRegistry
|
||||
# to register the same component multiple times in
|
||||
# different areas. if we do this more than once, let's
|
||||
# dry this out.
|
||||
class ModeToggleList extends ModeToggle
|
||||
@displayName: 'ModeToggleList'
|
||||
|
||||
module.exports =
|
||||
activate: (state) ->
|
||||
ComponentRegistry.register ModeToggleList,
|
||||
location: WorkspaceStore.Sheet.Thread.Toolbar.Right
|
||||
modes: ['list']
|
||||
|
||||
ComponentRegistry.register ModeToggle,
|
||||
location: WorkspaceStore.Sheet.Threads.Toolbar.Right
|
||||
modes: ['split']
|
34
internal_packages/mode-switch/lib/main.es6
Normal file
34
internal_packages/mode-switch/lib/main.es6
Normal file
|
@ -0,0 +1,34 @@
|
|||
import {ComponentRegistry, WorkspaceStore} from 'nylas-exports';
|
||||
import {HasTutorialTip} from 'nylas-component-kit';
|
||||
|
||||
import ModeToggle from './mode-toggle';
|
||||
|
||||
const ToggleWithTutorialTip = HasTutorialTip(ModeToggle, {
|
||||
title: 'Compose with Context',
|
||||
instructions: "N1 shows you everything about your contacts right inside your inbox. See LinkedIn profiles, Twitter bios, message history, and more.",
|
||||
});
|
||||
|
||||
// NOTE: this is a hack to allow ComponentRegistry
|
||||
// to register the same component multiple times in
|
||||
// different areas. if we do this more than once, let's
|
||||
// dry this out.
|
||||
class ToggleWithTutorialTipList extends ToggleWithTutorialTip {
|
||||
static displayName = 'ModeToggleList'
|
||||
}
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(ToggleWithTutorialTipList, {
|
||||
location: WorkspaceStore.Sheet.Thread.Toolbar.Right,
|
||||
modes: ['list'],
|
||||
});
|
||||
|
||||
ComponentRegistry.register(ToggleWithTutorialTip, {
|
||||
location: WorkspaceStore.Sheet.Threads.Toolbar.Right,
|
||||
modes: ['split'],
|
||||
});
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
ComponentRegistry.unregister(ToggleWithTutorialTip);
|
||||
ComponentRegistry.unregister(ToggleWithTutorialTipList);
|
||||
}
|
|
@ -21,7 +21,8 @@ class ModeToggle extends React.Component
|
|||
@_unsubscriber?()
|
||||
|
||||
render: =>
|
||||
<button className="btn btn-toolbar mode-toggle mode-#{@state.hidden}"
|
||||
<button
|
||||
className="btn btn-toolbar mode-toggle mode-#{@state.hidden}"
|
||||
style={order:500}
|
||||
title={if @state.hidden then "Show sidebar" else "Hide sidebar"}
|
||||
onClick={@_onToggleMode}>
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import {shell} from 'electron';
|
||||
import {React, IdentityStore} from 'nylas-exports';
|
||||
import {RetinaImg} from 'nylas-component-kit';
|
||||
|
||||
let NUM_TRIAL_DAYS = 30;
|
||||
const HANDLE_WIDTH = 100;
|
||||
|
||||
export default class TrialRemainingHeader extends React.Component {
|
||||
static displayName = "TrialRemainingHeader";
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = this.getStateFromStores();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._unlisten = IdentityStore.listen(() =>
|
||||
this.setState(this.getStateFromStores())
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._unlisten) {
|
||||
this._unlisten();
|
||||
}
|
||||
}
|
||||
|
||||
getStateFromStores = () => {
|
||||
const daysRemaining = IdentityStore.daysUntilSubscriptionRequired();
|
||||
if (daysRemaining > NUM_TRIAL_DAYS) {
|
||||
NUM_TRIAL_DAYS = daysRemaining;
|
||||
console.error("Unexpected number of days remaining in trial");
|
||||
}
|
||||
const inTrial = IdentityStore.subscriptionState() === IdentityStore.State.Trialing;
|
||||
const daysIntoTrial = NUM_TRIAL_DAYS - daysRemaining;
|
||||
const percentageIntoTrial = (NUM_TRIAL_DAYS - daysRemaining) / NUM_TRIAL_DAYS * 100;
|
||||
|
||||
return {
|
||||
inTrial,
|
||||
daysRemaining,
|
||||
daysIntoTrial,
|
||||
percentageIntoTrial,
|
||||
handleStyle: {
|
||||
left: `calc(${percentageIntoTrial}% - ${HANDLE_WIDTH / 2}px)`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
_onUpgrade = () => {
|
||||
this.setState({buildingUpgradeURL: true});
|
||||
const utm = {
|
||||
source: "UpgradeBanner",
|
||||
campaign: "TrialStillActive",
|
||||
}
|
||||
IdentityStore.fetchSingleSignOnURL('/payment', utm).then((url) => {
|
||||
this.setState({buildingUpgradeURL: false});
|
||||
shell.openExternal(url);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.inTrial && this.state.daysRemaining !== 0) {
|
||||
return (
|
||||
<div className="trial-remaining-header notifications-sticky">
|
||||
<div className="notifications-sticky-item">
|
||||
<RetinaImg
|
||||
className="icon"
|
||||
name="nylas-identity-seafoam.png"
|
||||
mode={RetinaImg.Mode.ContentPreserve}
|
||||
stype={{height: "20px"}}
|
||||
/>
|
||||
Nylas N1 is in Trial Mode
|
||||
<div className="trial-timer-wrapper">
|
||||
<div className="trial-timer-progress" style={{width: `${this.state.percentageIntoTrial}%`}}></div>
|
||||
<div className="trial-timer-handle" style={this.state.handleStyle}>
|
||||
{NUM_TRIAL_DAYS - this.state.daysIntoTrial} Days Left
|
||||
</div>
|
||||
</div>
|
||||
{this.state.daysIntoTrial}/{NUM_TRIAL_DAYS} Trial Days
|
||||
<button className="upgrade-to-pro" onClick={this._onUpgrade}>
|
||||
{this.state.buildingUpgradeURL ? "Please Wait..." : "Upgrade to Nylas Pro"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <span />;
|
||||
}
|
||||
}
|
|
@ -6,12 +6,14 @@ import NotificationStore from './notifications-store';
|
|||
import ConnectionStatusHeader from './headers/connection-status-header';
|
||||
import AccountErrorHeader from './headers/account-error-header';
|
||||
import NotificationsHeader from "./headers/notifications-header";
|
||||
import TrialRemainingHeader from "./headers/trial-remaining-header";
|
||||
|
||||
export function activate() {
|
||||
ComponentRegistry.register(ActivitySidebar, {location: WorkspaceStore.Location.RootSidebar});
|
||||
ComponentRegistry.register(NotificationsHeader, {location: WorkspaceStore.Sheet.Global.Header});
|
||||
ComponentRegistry.register(ConnectionStatusHeader, {location: WorkspaceStore.Sheet.Global.Header});
|
||||
ComponentRegistry.register(AccountErrorHeader, {location: WorkspaceStore.Sheet.Threads.Header});
|
||||
ComponentRegistry.register(TrialRemainingHeader, {location: WorkspaceStore.Sheet.Global.Header});
|
||||
}
|
||||
|
||||
export function serialize() {}
|
||||
|
@ -21,4 +23,5 @@ export function deactivate() {
|
|||
ComponentRegistry.unregister(NotificationsHeader);
|
||||
ComponentRegistry.unregister(ConnectionStatusHeader);
|
||||
ComponentRegistry.unregister(AccountErrorHeader);
|
||||
ComponentRegistry.unregister(TrialRemainingHeader);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
.trial-remaining-header {
|
||||
background-color: #f6f7f8;
|
||||
box-shadow: 0px 0px 3px #999;
|
||||
|
||||
.notifications-sticky-item {
|
||||
color: black;
|
||||
align-items: center;
|
||||
padding: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
border: solid #4b8e79 1px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.upgrade-to-pro {
|
||||
margin: 0 10px;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
background-color: #30A797;
|
||||
border: solid #30A797 1px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@handleWidth: 100px;
|
||||
@handleHeight: 30px;
|
||||
|
||||
.trial-timer-wrapper {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
height: 2px;
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
margin: 0 ~"calc(5px + @{handleWidth}/2)";
|
||||
}
|
||||
|
||||
.trial-timer-progress {
|
||||
position: relative;
|
||||
background-color: #30A797;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.trial-timer-handle {
|
||||
background-color: #f6f7f8;
|
||||
box-shadow: 0px 1px 3px #ccc;
|
||||
border: solid #ccc 1px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
padding: 0 15px;
|
||||
position: absolute;
|
||||
top: ~"calc(50% - @{handleHeight}/2)";
|
||||
color: #30A797;
|
||||
width: @handleWidth;
|
||||
height: @handleHeight;
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import {ComponentRegistry} from 'nylas-exports';
|
||||
import {HasTutorialTip} from 'nylas-component-kit';
|
||||
|
||||
import {ToolbarSnooze, QuickActionSnooze} from './snooze-buttons';
|
||||
import SnoozeMailLabel from './snooze-mail-label'
|
||||
import SnoozeStore from './snooze-store'
|
||||
|
@ -7,8 +9,13 @@ import SnoozeStore from './snooze-store'
|
|||
export function activate() {
|
||||
this.snoozeStore = new SnoozeStore()
|
||||
|
||||
const ToolbarSnoozeWithTutorialTip = HasTutorialTip(ToolbarSnooze, {
|
||||
title: "Handle it later!",
|
||||
instructions: "Snooze this email and it'll return to your inbox later. Click here or swipe across the thread in your inbox to snooze.",
|
||||
});
|
||||
|
||||
this.snoozeStore.activate()
|
||||
ComponentRegistry.register(ToolbarSnooze, {role: 'ThreadActionsToolbarButton'});
|
||||
ComponentRegistry.register(ToolbarSnoozeWithTutorialTip, {role: 'ThreadActionsToolbarButton'});
|
||||
ComponentRegistry.register(QuickActionSnooze, {role: 'ThreadListQuickAction'});
|
||||
ComponentRegistry.register(SnoozeMailLabel, {role: 'Thread:MailLabel'});
|
||||
}
|
||||
|
|
214
src/components/decorators/has-tutorial-tip.es6
Normal file
214
src/components/decorators/has-tutorial-tip.es6
Normal file
|
@ -0,0 +1,214 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import _ from 'underscore';
|
||||
|
||||
import {Actions} from 'nylas-exports';
|
||||
import NylasStore from 'nylas-store';
|
||||
|
||||
const TipsBackgroundEl = document.createElement('tutorial-tip-background');
|
||||
document.body.appendChild(TipsBackgroundEl);
|
||||
|
||||
|
||||
class TipsStore extends NylasStore {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._tipKeys = [];
|
||||
}
|
||||
|
||||
isTipVisible(key) {
|
||||
const seen = NylasEnv.config.get('core.tutorial.seen') || [];
|
||||
return this._tipKeys.find(t => !seen.includes(t)) === key;
|
||||
}
|
||||
|
||||
hasSeenTip(key) {
|
||||
return (NylasEnv.config.get('core.tutorial.seen') || []).includes(key);
|
||||
}
|
||||
|
||||
// Actions: Since this is a private store just inside this file, we call
|
||||
// these methods directly for now.
|
||||
|
||||
mountedTip = (key) => {
|
||||
if (!this._tipKeys.includes(key)) {
|
||||
this._tipKeys.push(key);
|
||||
}
|
||||
this.trigger();
|
||||
}
|
||||
|
||||
seenTip = (key) => {
|
||||
this._tipKeys = this._tipKeys.filter(t => t !== key);
|
||||
NylasEnv.config.pushAtKeyPath('core.tutorial.seen', key);
|
||||
this.trigger();
|
||||
}
|
||||
|
||||
unmountedTip = (key) => {
|
||||
this._tipKeys = this._tipKeys.filter(t => t !== key);
|
||||
this.trigger();
|
||||
}
|
||||
}
|
||||
|
||||
TipsStore = new TipsStore();
|
||||
|
||||
class TipPopoverContents extends React.Component {
|
||||
static propTypes = {
|
||||
title: React.PropTypes.string,
|
||||
tipKey: React.PropTypes.string,
|
||||
instructions: React.PropTypes.oneOfType([React.PropTypes.string, React.PropTypes.element]),
|
||||
onDismissed: React.PropTypes.func,
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
TipsBackgroundEl.classList.add('visible');
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
TipsBackgroundEl.classList.remove('visible');
|
||||
if (this.props.onDismissed) {
|
||||
this.props.onDismissed();
|
||||
}
|
||||
}
|
||||
|
||||
onDone = () => {
|
||||
TipsStore.seenTip(this.props.tipKey);
|
||||
Actions.closePopover();
|
||||
}
|
||||
|
||||
render() {
|
||||
let content = null;
|
||||
|
||||
if (typeof(this.props.instructions) === 'string') {
|
||||
content = <p dangerouslySetInnerHTML={{__html: this.props.instructions}} />;
|
||||
} else {
|
||||
content = <p>{this.props.instructions}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{width: 250, padding: 20, paddingTop: 0}}>
|
||||
<h2>{this.props.title}</h2>
|
||||
{content}
|
||||
<button className="btn" onClick={this.onDone}>Got it!</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function HasTutorialTip(ComposedComponent, TipConfig) {
|
||||
const TipKey = ComposedComponent.displayName;
|
||||
|
||||
if (TipsStore.hasSeenTip(TipKey)) {
|
||||
return ComposedComponent;
|
||||
}
|
||||
|
||||
return class extends ComposedComponent {
|
||||
static displayName = ComposedComponent.displayName;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {visible: false};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
TipsStore.mountedTip(TipKey);
|
||||
|
||||
this._unlisten = TipsStore.listen(this._onTooltipStateChanged);
|
||||
window.addEventListener('resize', this._onRecomputeTooltipPosition);
|
||||
|
||||
// unfortunately, we can't render() a container around ComposedComponent
|
||||
// without modifying the DOM tree and messing with things like flexbox.
|
||||
// Instead, we leave render() unchanged and attach the bubble and hover
|
||||
// listeners to the DOM manually.
|
||||
|
||||
this.tipNode = document.createElement('div');
|
||||
this.tipNode.classList.add('tutorial-tip');
|
||||
document.body.appendChild(this.tipNode);
|
||||
|
||||
const el = ReactDOM.findDOMNode(this);
|
||||
el.addEventListener('mouseover', this._onMouseOver);
|
||||
this._onTooltipStateChanged();
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.state.visible) {
|
||||
this._onRecomputeTooltipPosition();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._unlisten();
|
||||
|
||||
window.removeEventListener('resize', this._onRecomputeTooltipPosition);
|
||||
document.body.removeChild(this.tipNode);
|
||||
|
||||
TipsStore.unmountedTip(TipKey);
|
||||
}
|
||||
|
||||
_onTooltipStateChanged = () => {
|
||||
const visible = TipsStore.isTipVisible(TipKey);
|
||||
|
||||
if (this.state.visible !== visible) {
|
||||
this.setState({visible});
|
||||
if (visible) {
|
||||
this.tipNode.classList.add('visible');
|
||||
this._onRecomputeTooltipPosition();
|
||||
} else {
|
||||
this.tipNode.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onMouseOver = () => {
|
||||
if (!this.state.visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const el = ReactDOM.findDOMNode(this);
|
||||
el.removeEventListener('mouseover', this._onMouseOver);
|
||||
|
||||
const tipRect = this.tipNode.getBoundingClientRect();
|
||||
const rect = ReactDOM.findDOMNode(this).getBoundingClientRect();
|
||||
const rectCX = rect.left + rect.width / 2;
|
||||
const rectCY = rect.top + rect.height / 2;
|
||||
TipsBackgroundEl.style.background = `
|
||||
-webkit-radial-gradient(
|
||||
${Math.round(rectCX / window.innerWidth * 100)}%
|
||||
${Math.round(rectCY / window.innerHeight * 100)}%,
|
||||
circle, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 3%, rgba(0, 0, 0, 0.2) 5%)
|
||||
`;
|
||||
Actions.openPopover((
|
||||
<TipPopoverContents
|
||||
tipKey={TipKey}
|
||||
title={TipConfig.title}
|
||||
instructions={TipConfig.instructions}
|
||||
onDismissed={() => {
|
||||
el.addEventListener('mouseover', this._onMouseOver);
|
||||
}}
|
||||
/>
|
||||
), {
|
||||
originRect: tipRect,
|
||||
direction: 'down',
|
||||
fallbackDirection: 'up',
|
||||
})
|
||||
}
|
||||
|
||||
_onRecomputeTooltipPosition = () => {
|
||||
const el = ReactDOM.findDOMNode(this);
|
||||
let settled = 0;
|
||||
let last = {};
|
||||
const attempt = () => {
|
||||
const {left, top} = el.getBoundingClientRect();
|
||||
this.tipNode.style.left = `${left + 5}px`;
|
||||
this.tipNode.style.top = `${top + 5}px`;
|
||||
|
||||
if (!_.isEqual(last, {left, top})) {
|
||||
settled = 0;
|
||||
last = {left, top};
|
||||
}
|
||||
settled += 1;
|
||||
if (settled < 5) {
|
||||
window.requestAnimationFrame(this._onRecomputeTooltipPosition);
|
||||
}
|
||||
}
|
||||
attempt();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -116,7 +116,12 @@ export default class MetadataComposerToggleButton extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<button className={className} onClick={this._onClick} title={title} tabIndex={-1}>
|
||||
<button
|
||||
className={className}
|
||||
onClick={this._onClick}
|
||||
title={title}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RetinaImg {...attrs} mode={RetinaImg.Mode.ContentIsMask} />
|
||||
</button>
|
||||
);
|
||||
|
|
|
@ -93,5 +93,6 @@ class NylasComponentKit
|
|||
@load "ListensToObservable", 'decorators/listens-to-observable'
|
||||
@load "ListensToFluxStore", 'decorators/listens-to-flux-store'
|
||||
@load "ListensToMovementKeys", 'decorators/listens-to-movement-keys'
|
||||
@load "HasTutorialTip", 'decorators/has-tutorial-tip'
|
||||
|
||||
module.exports = new NylasComponentKit()
|
||||
|
|
|
@ -143,6 +143,8 @@ class NylasExports
|
|||
@lazyLoadAndRegisterStore "SearchableComponentStore", 'searchable-component-store'
|
||||
@lazyLoad "CustomContenteditableComponents", 'components/overlaid-components/custom-contenteditable-components'
|
||||
|
||||
@lazyLoad "ServiceRegistry", "service-registry"
|
||||
|
||||
# Decorators
|
||||
@lazyLoad "InflatesDraftClientId", 'decorators/inflates-draft-client-id'
|
||||
|
||||
|
|
2
src/pro
2
src/pro
|
@ -1 +1 @@
|
|||
Subproject commit 736c1ad8017e934ad01736888e9892f05ddb2268
|
||||
Subproject commit b47405f99f77fe3149ddb55453013ab1058031a8
|
31
src/service-registry.es6
Normal file
31
src/service-registry.es6
Normal file
|
@ -0,0 +1,31 @@
|
|||
class ServiceRegistry {
|
||||
constructor() {
|
||||
this._waitingForServices = {};
|
||||
this._services = {};
|
||||
}
|
||||
|
||||
withService(name, callback) {
|
||||
if (this._services[name]) {
|
||||
process.nextTick(() => callback(this._services[name]));
|
||||
} else {
|
||||
this._waitingForServices[name] = this._waitingForServices[name] || [];
|
||||
this._waitingForServices[name].push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
registerService(name, obj) {
|
||||
this._services[name] = obj;
|
||||
if (this._waitingForServices[name]) {
|
||||
for (const callback of this._waitingForServices[name]) {
|
||||
callback(obj);
|
||||
}
|
||||
delete this._waitingForServices[name];
|
||||
}
|
||||
}
|
||||
|
||||
unregisterService(name) {
|
||||
delete this._services[name];
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceRegistry()
|
78
static/components/tutorial-overlay.less
Normal file
78
static/components/tutorial-overlay.less
Normal file
|
@ -0,0 +1,78 @@
|
|||
tutorial-tip-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: -webkit-radial-gradient(50% 50%, circle, rgba(0,0,0,0) 0%, rgba(0,0,0,0) 4%, rgba(0,0,0,0.2) 7%);
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity ease-in-out 250ms;
|
||||
}
|
||||
|
||||
tutorial-tip-background.visible {
|
||||
pointer-events: inherit;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tutorial-tip {
|
||||
position: absolute;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
border: 2px solid white;
|
||||
cursor: pointer;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
transform: translate3d(-50%,-50%,0);
|
||||
background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(rgb(241, 170, 211)), to(rgb(185, 59, 255)));
|
||||
opacity: 0;
|
||||
transition: opacity ease-out 100ms;
|
||||
}
|
||||
|
||||
.tutorial-tip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.tutorial-tip.visible:after {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
-webkit-box-sizing: content-box;
|
||||
-moz-box-sizing: content-box;
|
||||
box-sizing: content-box;
|
||||
|
||||
box-shadow: 0 0 0 2px rgb(241, 170, 211);
|
||||
|
||||
animation: sonarEffect 2s ease-out 75ms;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@-webkit-keyframes sonarEffect {
|
||||
0% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
40% {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5);
|
||||
}
|
||||
80% {
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5);
|
||||
-webkit-transform: scale(1.4);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,0.1), 0 0 10px 10px #fff, 0 0 0 10px rgba(255,255,255,0.5);
|
||||
-webkit-transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
BIN
static/images/notification/nylas-identity-seafoam@1x.png
Normal file
BIN
static/images/notification/nylas-identity-seafoam@1x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 749 B |
BIN
static/images/notification/nylas-identity-seafoam@2x.png
Normal file
BIN
static/images/notification/nylas-identity-seafoam@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
|
@ -40,3 +40,4 @@
|
|||
@import "components/table";
|
||||
@import "components/editable-table";
|
||||
@import "components/multiselect-dropdown";
|
||||
@import "components/tutorial-overlay";
|
||||
|
|
Loading…
Reference in a new issue