feat: new lock screen and challenge modal design (#957)

This commit is contained in:
Aman Harwara
2022-04-12 00:05:08 +05:30
committed by GitHub
parent 3a2ff2f440
commit c16f23a75f
12 changed files with 434 additions and 385 deletions

View File

@@ -17,7 +17,7 @@ import { NoteGroupView } from '@/components/NoteGroupView';
import { Footer } from '@/components/Footer';
import { SessionsModal } from '@/components/SessionsModal';
import { PreferencesViewWrapper } from '@/components/Preferences/PreferencesViewWrapper';
import { ChallengeModal } from '@/components/ChallengeModal';
import { ChallengeModal } from '@/components/ChallengeModal/ChallengeModal';
import { NotesContextMenu } from '@/components/NotesContextMenu';
import { PurchaseFlowWrapper } from '@/components/PurchaseFlow/PurchaseFlowWrapper';
import { render } from 'preact';

View File

@@ -22,8 +22,8 @@ const getClassName = (
let focusHoverStates =
variant === 'normal'
? 'focus:bg-contrast hover:bg-contrast'
: 'hover:brightness-130 focus:brightness-130';
? 'focus:bg-contrast focus:outline-none hover:bg-contrast'
: 'hover:brightness-130 focus:outline-none focus:brightness-130';
if (danger) {
colors =
@@ -39,8 +39,8 @@ const getClassName = (
: 'bg-grey-2 color-info-contrast';
focusHoverStates =
variant === 'normal'
? 'focus:bg-default hover:bg-default'
: 'focus:brightness-default hover:brightness-default';
? 'focus:bg-default focus:outline-none hover:bg-default'
: 'focus:brightness-default focus:outline-none hover:brightness-default';
}
return `${baseClass} ${colors} ${borders} ${focusHoverStates} ${cursor}`;

View File

@@ -1,371 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { Dialog } from '@reach/dialog';
import {
ChallengeValue,
removeFromArray,
Challenge,
ChallengeReason,
ChallengePrompt,
ChallengeValidation,
ProtectionSessionDurations,
} from '@standardnotes/snjs';
import { confirmDialog } from '@/services/alertService';
import { STRING_SIGN_OUT_CONFIRMATION } from '@/strings';
import { createRef } from 'preact';
import { PureComponent } from '@/components/Abstract/PureComponent';
type InputValue = {
prompt: ChallengePrompt;
value: string | number | boolean;
invalid: boolean;
};
type Values = Record<number, InputValue>;
type State = {
prompts: ChallengePrompt[];
values: Partial<Values>;
processing: boolean;
forgotPasscode: boolean;
showForgotPasscodeLink: boolean;
processingPrompts: ChallengePrompt[];
hasAccount: boolean;
protectedNoteAccessDuration: number;
};
type Props = {
challenge: Challenge;
application: WebApplication;
onDismiss: (challenge: Challenge) => void;
};
export class ChallengeModal extends PureComponent<Props, State> {
submitting = false;
protectionsSessionDurations = ProtectionSessionDurations;
protectionsSessionValidation = ChallengeValidation.ProtectionSessionDuration;
private initialFocusRef = createRef<HTMLInputElement>();
constructor(props: Props) {
super(props, props.application);
const values = {} as Values;
const prompts = this.props.challenge.prompts;
for (const prompt of prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
};
}
const showForgotPasscodeLink = [
ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration,
].includes(this.props.challenge.reason);
this.state = {
prompts,
values,
processing: false,
forgotPasscode: false,
showForgotPasscodeLink,
hasAccount: this.application.hasAccount(),
processingPrompts: [],
protectedNoteAccessDuration: ProtectionSessionDurations[0].valueInSeconds,
};
}
componentDidMount(): void {
super.componentDidMount();
this.application.addChallengeObserver(this.props.challenge, {
onValidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = false;
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
this.afterStateChange();
},
onInvalidValue: (value) => {
this.state.values[value.prompt.id]!.invalid = true;
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
this.setState({ processingPrompts: [], processing: false });
} else {
removeFromArray(this.state.processingPrompts, value.prompt);
this.reloadProcessingStatus();
}
this.afterStateChange();
},
onComplete: () => {
this.dismiss();
},
onCancel: () => {
this.dismiss();
},
});
}
deinit() {
(this.application as unknown) = undefined;
(this.props.challenge as unknown) = undefined;
super.deinit();
}
reloadProcessingStatus() {
return this.setState({
processing: this.state.processingPrompts.length > 0,
});
}
destroyLocalData = async () => {
if (
await confirmDialog({
text: STRING_SIGN_OUT_CONFIRMATION,
confirmButtonStyle: 'danger',
})
) {
this.dismiss();
this.application.user.signOut();
}
};
cancel = () => {
if (this.props.challenge.cancelable) {
this.application.cancelChallenge(this.props.challenge);
}
};
onForgotPasscodeClick = () => {
this.setState({
forgotPasscode: true,
});
};
onTextValueChange = (prompt: ChallengePrompt) => {
const values = this.state.values;
values[prompt.id]!.invalid = false;
this.setState({ values });
};
onNumberValueChange(prompt: ChallengePrompt, value: number) {
const values = this.state.values;
values[prompt.id]!.invalid = false;
values[prompt.id]!.value = value;
this.setState({ values });
}
validate() {
let failed = 0;
for (const prompt of this.state.prompts) {
const value = this.state.values[prompt.id]!;
if (typeof value.value === 'string' && value.value.length === 0) {
this.state.values[prompt.id]!.invalid = true;
failed++;
}
}
return failed === 0;
}
submit = async () => {
if (!this.validate()) {
return;
}
if (this.submitting || this.state.processing) {
return;
}
this.submitting = true;
await this.setState({ processing: true });
const values: ChallengeValue[] = [];
for (const inputValue of Object.values(this.state.values)) {
const rawValue = inputValue!.value;
const value = new ChallengeValue(inputValue!.prompt, rawValue);
values.push(value);
}
const processingPrompts = values.map((v) => v.prompt);
await this.setState({
processingPrompts: processingPrompts,
processing: processingPrompts.length > 0,
});
/**
* Unfortunately neccessary to wait 50ms so that the above setState call completely
* updates the UI to change processing state, before we enter into UI blocking operation
* (crypto key generation)
*/
setTimeout(() => {
if (values.length > 0) {
this.application.submitValuesForChallenge(this.props.challenge, values);
} else {
this.setState({ processing: false });
}
this.submitting = false;
}, 50);
};
afterStateChange() {
this.render();
}
dismiss = () => {
this.props.onDismiss(this.props.challenge);
};
private renderChallengePrompts() {
return this.state.prompts.map((prompt, index) => (
<>
{/** ProtectionSessionDuration can't just be an input field */}
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div key={prompt.id} className="sk-panel-row">
<div className="sk-horizontal-group mt-3">
<div className="sk-p sk-bold">Allow protected access for</div>
{ProtectionSessionDurations.map((option) => (
<a
className={
'sk-a info ' +
(option.valueInSeconds ===
this.state.values[prompt.id]!.value
? 'boxed'
: '')
}
onClick={(event) => {
event.preventDefault();
this.onNumberValueChange(prompt, option.valueInSeconds);
}}
>
{option.label}
</a>
))}
</div>
</div>
) : (
<div key={prompt.id} className="sk-panel-row">
<form
className="w-full"
onSubmit={(event) => {
event.preventDefault();
this.submit();
}}
>
<input
className="sk-input contrast"
value={this.state.values[prompt.id]!.value as string | number}
onChange={(event) => {
const value = (event.target as HTMLInputElement).value;
this.state.values[prompt.id]!.value = value;
this.onTextValueChange(prompt);
}}
ref={index === 0 ? this.initialFocusRef : undefined}
placeholder={prompt.title}
type={prompt.secureTextEntry ? 'password' : 'text'}
/>
</form>
</div>
)}
{this.state.values[prompt.id]!.invalid && (
<div className="sk-panel-row centered">
<label className="sk-label danger">
Invalid authentication. Please try again.
</label>
</div>
)}
</>
));
}
render() {
if (!this.state.prompts) {
return <></>;
}
return (
<Dialog
initialFocusRef={this.initialFocusRef}
onDismiss={() => {
if (this.props.challenge.cancelable) {
this.cancel();
}
}}
>
<div className="challenge-modal sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-header">
<div className="sk-panel-header-title">
{this.props.challenge.modalTitle}
</div>
</div>
<div className="sk-panel-content">
<div className="sk-panel-section">
<div className="sk-p sk-panel-row centered prompt">
<strong>{this.props.challenge.heading}</strong>
</div>
{this.props.challenge.subheading && (
<div className="sk-p sk-panel-row centered subprompt">
{this.props.challenge.subheading}
</div>
)}
</div>
<div className="sk-panel-section">
{this.renderChallengePrompts()}
</div>
</div>
<div className="sk-panel-footer extra-padding">
<button
className={
'sn-button w-full ' +
(this.state.processing ? 'neutral' : 'info')
}
disabled={this.state.processing}
onClick={() => this.submit()}
>
{this.state.processing ? 'Generating Keys…' : 'Submit'}
</button>
{this.props.challenge.cancelable && (
<>
<div className="sk-panel-row"></div>
<a
className="sk-panel-row sk-a info centered text-sm"
onClick={() => this.cancel()}
>
Cancel
</a>
</>
)}
</div>
{this.state.showForgotPasscodeLink && (
<div className="sk-panel-footer">
{this.state.forgotPasscode ? (
<>
<p className="sk-panel-row sk-p text-center">
{this.state.hasAccount
? 'If you forgot your application passcode, your ' +
'only option is to clear your local data from this ' +
'device and sign back in to your account.'
: 'If you forgot your application passcode, your ' +
'only option is to delete your data.'}
</p>
<a
className="sk-panel-row sk-a danger centered"
onClick={() => {
this.destroyLocalData();
}}
>
Delete Local Data
</a>
</>
) : (
<a
className="sk-panel-row sk-a info centered"
onClick={() => this.onForgotPasscodeClick()}
>
Forgot your passcode?
</a>
)}
<div className="sk-panel-row"></div>
</div>
)}
</div>
</div>
</div>
</Dialog>
);
}
}

View File

@@ -0,0 +1,270 @@
import { WebApplication } from '@/ui_models/application';
import { DialogContent, DialogOverlay } from '@reach/dialog';
import {
ButtonType,
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValue,
removeFromArray,
} from '@standardnotes/snjs';
import { ProtectedIllustration } from '@standardnotes/stylekit';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useState } from 'preact/hooks';
import { Button } from '../Button';
import { Icon } from '../Icon';
import { ChallengeModalPrompt } from './ChallengePrompt';
type InputValue = {
prompt: ChallengePrompt;
value: string | number | boolean;
invalid: boolean;
};
export type ChallengeModalValues = Record<ChallengePrompt['id'], InputValue>;
type Props = {
application: WebApplication;
challenge: Challenge;
onDismiss: (challenge: Challenge) => Promise<void>;
};
const validateValues = (
values: ChallengeModalValues,
prompts: ChallengePrompt[]
): ChallengeModalValues | undefined => {
let hasInvalidValues = false;
const validatedValues = { ...values };
for (const prompt of prompts) {
const value = validatedValues[prompt.id];
if (typeof value.value === 'string' && value.value.length === 0) {
validatedValues[prompt.id].invalid = true;
hasInvalidValues = true;
}
}
if (!hasInvalidValues) {
return validatedValues;
}
};
export const ChallengeModal: FunctionComponent<Props> = ({
application,
challenge,
onDismiss,
}) => {
const [values, setValues] = useState<ChallengeModalValues>(() => {
const values = {} as ChallengeModalValues;
for (const prompt of challenge.prompts) {
values[prompt.id] = {
prompt,
value: prompt.initialValue ?? '',
invalid: false,
};
}
return values;
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([]);
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false);
const shouldShowForgotPasscode = [
ChallengeReason.ApplicationUnlock,
ChallengeReason.Migration,
].includes(challenge.reason);
const submit = async () => {
const validatedValues = validateValues(values, challenge.prompts);
if (!validatedValues) {
return;
}
if (isSubmitting || isProcessing) {
return;
}
setIsSubmitting(true);
setIsProcessing(true);
const valuesToProcess: ChallengeValue[] = [];
for (const inputValue of Object.values(validatedValues)) {
const rawValue = inputValue.value;
const value = new ChallengeValue(inputValue.prompt, rawValue);
valuesToProcess.push(value);
}
const processingPrompts = valuesToProcess.map((v) => v.prompt);
setIsProcessing(processingPrompts.length > 0);
setProcessingPrompts(processingPrompts);
/**
* Unfortunately neccessary to wait 50ms so that the above setState call completely
* updates the UI to change processing state, before we enter into UI blocking operation
* (crypto key generation)
*/
setTimeout(() => {
if (valuesToProcess.length > 0) {
application.submitValuesForChallenge(challenge, valuesToProcess);
} else {
setIsProcessing(false);
}
setIsSubmitting(false);
}, 50);
};
const onValueChange = useCallback(
(value: string | number, prompt: ChallengePrompt) => {
const newValues = { ...values };
newValues[prompt.id].invalid = false;
newValues[prompt.id].value = value;
setValues(newValues);
},
[values]
);
const closeModal = () => {
if (challenge.cancelable) {
onDismiss(challenge);
}
};
useEffect(() => {
const removeChallengeObserver = application.addChallengeObserver(
challenge,
{
onValidValue: (value) => {
setValues((values) => {
const newValues = { ...values };
newValues[value.prompt.id].invalid = false;
return newValues;
});
setProcessingPrompts((currentlyProcessingPrompts) => {
const processingPrompts = currentlyProcessingPrompts.slice();
removeFromArray(processingPrompts, value.prompt);
setIsProcessing(processingPrompts.length > 0);
return processingPrompts;
});
},
onInvalidValue: (value) => {
setValues((values) => {
const newValues = { ...values };
newValues[value.prompt.id].invalid = true;
return newValues;
});
/** If custom validation, treat all values together and not individually */
if (!value.prompt.validates) {
setProcessingPrompts([]);
setIsProcessing(false);
} else {
setProcessingPrompts((currentlyProcessingPrompts) => {
const processingPrompts = currentlyProcessingPrompts.slice();
removeFromArray(processingPrompts, value.prompt);
setIsProcessing(processingPrompts.length > 0);
return processingPrompts;
});
}
},
onComplete: () => {
onDismiss(challenge);
},
onCancel: () => {
onDismiss(challenge);
},
}
);
return () => {
removeChallengeObserver();
};
}, [application, challenge, onDismiss]);
if (!challenge.prompts) {
return null;
}
return (
<DialogOverlay
className={`sn-component ${
challenge.reason === ChallengeReason.ApplicationUnlock
? 'challenge-modal-overlay'
: ''
}`}
onDismiss={closeModal}
dangerouslyBypassFocusLock={bypassModalFocusLock}
>
<DialogContent
className={`challenge-modal flex flex-col items-center bg-default p-8 rounded relative ${
challenge.reason !== ChallengeReason.ApplicationUnlock
? 'shadow-overlay-light border-1 border-solid border-main'
: 'focus:shadow-none'
}`}
>
{challenge.cancelable && (
<button
onClick={closeModal}
aria-label="Close modal"
className="flex p-1 bg-transparent border-0 cursor-pointer absolute top-4 right-4"
>
<Icon type="close" className="color-neutral" />
</button>
)}
<ProtectedIllustration className="w-30 h-30 mb-4" />
<div className="font-bold text-lg text-center max-w-76 mb-3">
{challenge.heading}
</div>
<div className="text-center text-sm max-w-76 mb-4">
{challenge.subheading}
</div>
<form
className="flex flex-col items-center min-w-76 mb-4"
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
{challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt
key={prompt.id}
prompt={prompt}
values={values}
index={index}
onValueChange={onValueChange}
isInvalid={values[prompt.id].invalid}
/>
))}
</form>
<Button
variant="primary"
disabled={isProcessing}
className="min-w-76 mb-3.5"
onClick={() => {
submit();
}}
>
{isProcessing ? 'Generating Keys...' : 'Unlock'}
</Button>
{shouldShowForgotPasscode && (
<Button
className="flex items-center justify-center min-w-76"
onClick={() => {
setBypassModalFocusLock(true);
application.alertService
.confirm(
'If you forgot your local passcode, your only option is to clear your local data from this device and sign back in to your account.',
'Forgot passcode?',
'Delete local data',
ButtonType.Danger
)
.then((shouldDeleteLocalData) => {
if (shouldDeleteLocalData) {
application.user.signOut();
}
})
.catch(console.error)
.finally(() => {
setBypassModalFocusLock(false);
});
}}
>
<Icon type="help" className="mr-2 color-neutral" />
Forgot passcode?
</Button>
)}
</DialogContent>
</DialogOverlay>
);
};

View File

@@ -0,0 +1,103 @@
import {
ChallengePrompt,
ChallengeValidation,
ProtectionSessionDurations,
} from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { DecoratedInput } from '../DecoratedInput';
import { DecoratedPasswordInput } from '../DecoratedPasswordInput';
import { ChallengeModalValues } from './ChallengeModal';
type Props = {
prompt: ChallengePrompt;
values: ChallengeModalValues;
index: number;
onValueChange: (value: string | number, prompt: ChallengePrompt) => void;
isInvalid: boolean;
};
export const ChallengeModalPrompt: FunctionComponent<Props> = ({
prompt,
values,
index,
onValueChange,
isInvalid,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (index === 0) {
inputRef.current?.focus();
}
}, [index]);
useEffect(() => {
if (isInvalid) {
inputRef.current?.focus();
}
}, [isInvalid]);
return (
<>
{prompt.validation === ChallengeValidation.ProtectionSessionDuration ? (
<div className="mt-3 min-w-76">
<div className="text-sm font-medium mb-2">
Allow protected access for
</div>
<div className="flex items-center justify-between bg-grey-4 rounded p-1">
{ProtectionSessionDurations.map((option) => {
const selected =
option.valueInSeconds === values[prompt.id].value;
return (
<label
className={`cursor-pointer px-2 py-1.5 rounded ${
selected
? 'bg-default color-foreground font-semibold'
: 'color-grey-0 hover:bg-grey-3'
}`}
>
<input
type="radio"
name={`session-duration-${prompt.id}`}
className={
'appearance-none m-0 focus:shadow-none focus:outline-none'
}
style={{
marginRight: 0,
}}
checked={selected}
onChange={(event) => {
event.preventDefault();
onValueChange(option.valueInSeconds, prompt);
}}
/>
{option.label}
</label>
);
})}
</div>
</div>
) : prompt.secureTextEntry ? (
<DecoratedPasswordInput
ref={inputRef}
placeholder={prompt.placeholder}
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
onChange={(value) => onValueChange(value, prompt)}
/>
) : (
<DecoratedInput
ref={inputRef}
placeholder={prompt.placeholder}
className={`w-full max-w-76 ${isInvalid ? 'border-danger' : ''}`}
onChange={(value) => onValueChange(value, prompt)}
/>
)}
{isInvalid && (
<div className="text-sm color-danger mt-2">
Invalid authentication, please try again.
</div>
)}
</>
);
};

View File

@@ -23,7 +23,7 @@ const getClassNames = (
container: `flex items-stretch position-relative bg-default border-1 border-solid border-main rounded focus-within:ring-info overflow-hidden ${
!hasLeftDecorations && !hasRightDecorations ? 'px-2 py-1.5' : ''
}`,
input: `w-full border-0 focus:shadow-none ${
input: `w-full border-0 focus:shadow-none bg-transparent ${
!hasLeftDecorations && hasRightDecorations ? 'pl-2' : ''
} ${hasRightDecorations ? 'pr-2' : ''}`,
disabled: 'bg-grey-5 cursor-not-allowed',

View File

@@ -9,7 +9,7 @@ const Toggle: FunctionComponent<{
setIsToggled: StateUpdater<boolean>;
}> = ({ isToggled, setIsToggled }) => (
<IconButton
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4"
className="w-5 h-5 justify-center sk-circle hover:bg-grey-4 color-neutral"
icon={isToggled ? 'eye-off' : 'eye'}
iconClassName="sn-icon--small"
title="Show/hide password"

View File

@@ -44,6 +44,7 @@ export const IconButton: FunctionComponent<Props> = ({
const focusableClass = focusable ? '' : 'focus:shadow-none';
return (
<button
type="button"
title={title}
className={`no-border cursor-pointer bg-transparent flex flex-row items-center hover:brightness-130 p-0 ${focusableClass} ${className}`}
onClick={click}

View File

@@ -5,6 +5,7 @@ import {
VNode,
RefCallback,
ComponentChild,
toChildArray,
} from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { JSXInternal } from 'preact/src/jsx';
@@ -117,7 +118,7 @@ export const Menu: FunctionComponent<MenuProps> = ({
style={style}
aria-label={a11yLabel}
>
{Array.isArray(children) ? children.map(mapMenuItems) : null}
{toChildArray(children).map(mapMenuItems)}
</menu>
);
};

View File

@@ -12,8 +12,7 @@
}
.challenge-modal {
max-width: 480px;
min-width: 400px !important;
min-width: 0 !important;
.prompt, .subprompt {
text-align: center;
@@ -21,9 +20,6 @@
.sk-panel .sk-panel-header {
justify-content: center;
}
input {
text-align: center;
}
}
#account-switcher {

View File

@@ -17,6 +17,9 @@
left: 0px;
opacity: 0.75;
}
.challenge-modal-overlay::before {
background-color: var(--sn-stylekit-grey-5);
}
[data-reach-dialog-content] {
width: auto;

View File

@@ -246,6 +246,10 @@
margin-left: 0.75rem;
}
.sn-component .mr-0 {
margin-right: 0;
}
.mr-2\.5 {
margin-right: 0.625rem;
}
@@ -298,6 +302,10 @@
margin-bottom: 0.5rem;
}
.mb-3\.5 {
margin-bottom: 0.75rem;
}
.max-w-40\% {
max-width: 40%;
}
@@ -314,10 +322,18 @@
max-width: 80%;
}
.max-w-68 {
max-width: 17rem;
}
.max-w-72 {
max-width: 18rem;
}
.max-w-76 {
max-width: 19rem;
}
.max-w-89 {
max-width: 22.25rem;
}
@@ -397,6 +413,10 @@
min-width: 17.5rem;
}
.min-w-76 {
min-width: 19rem;
}
.min-w-24 {
min-width: 6rem;
}
@@ -457,7 +477,7 @@
max-height: 27.5rem;
}
.border-danger {
.sn-component .border-danger {
border-color: var(--sn-stylekit-danger-color);
}
@@ -501,6 +521,10 @@
padding: 0.375rem;
}
.p-6 {
padding: 1.5rem;
}
.p-8 {
padding: 2rem;
}
@@ -610,6 +634,11 @@
padding-bottom: 2.25rem;
}
.py-12 {
padding-top: 3rem;
padding-bottom: 3rem;
}
.pl-2 {
padding-left: 0.5rem;
}
@@ -626,6 +655,10 @@
position: sticky;
}
.top-4 {
top: 1rem;
}
.top-30\% {
top: 30%;
}
@@ -694,6 +727,10 @@
right: 0.5rem;
}
.right-4 {
right: 1rem;
}
.-right-2 {
right: -0.5rem;
}
@@ -1054,6 +1091,10 @@
vertical-align: middle;
}
.shadow-overlay-light {
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.05);
}
.sn-component .focus\:brightness-default:focus {
filter: brightness(100%);
}
@@ -1062,6 +1103,11 @@
filter: brightness(100%);
}
.appearance-none {
-webkit-appearance: none;
appearance: none;
}
.sn-component .focus\:bg-default:focus {
background-color: var(--sn-stylekit-background-color);
}