[CL-219][CL-218][CL-217] Add new extension layout components (#8728)

This commit is contained in:
Victoria League 2024-04-26 09:35:32 -04:00 committed by GitHub
parent 2fa4c6e4f9
commit ec37e5e4d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 711 additions and 13 deletions

View File

@ -9,6 +9,8 @@ const config: StorybookConfig = {
"../libs/components/src/**/*.stories.@(js|jsx|ts|tsx)",
"../apps/web/src/**/*.mdx",
"../apps/web/src/**/*.stories.@(js|jsx|ts|tsx)",
"../apps/browser/src/**/*.mdx",
"../apps/browser/src/**/*.stories.@(js|jsx|ts|tsx)",
"../bitwarden_license/bit-web/src/**/*.mdx",
"../bitwarden_license/bit-web/src/**/*.stories.@(js|jsx|ts|tsx)",
],

View File

@ -1,12 +1,10 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"types": ["node", "jest", "chrome"],
"allowSyntheticDefaultImports": true
},
"exclude": ["../src/test.setup.ts", "../apps/src/**/*.spec.ts", "../libs/**/*.spec.ts"],
"exclude": ["../src/test.setup.ts", "../apps/**/*.spec.ts", "../libs/**/*.spec.ts"],
"files": [
"./typings.d.ts",
"./preview.tsx",
"../libs/components/src/main.ts",
"../libs/components/src/polyfills.ts"

View File

@ -1,4 +0,0 @@
declare module "*.md" {
const content: string;
export default content;
}

View File

@ -0,0 +1,9 @@
<footer
class="tw-p-3 tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-bg-background"
>
<div class="tw-max-w-screen-sm tw-mx-auto">
<div class="tw-flex tw-justify-start tw-gap-2">
<ng-content></ng-content>
</div>
</div>
</footer>

View File

@ -0,0 +1,9 @@
import { Component } from "@angular/core";
@Component({
selector: "popup-footer",
templateUrl: "popup-footer.component.html",
standalone: true,
imports: [],
})
export class PopupFooterComponent {}

View File

@ -0,0 +1,19 @@
<header
class="tw-p-4 tw-border-0 tw-border-solid tw-border-b tw-border-secondary-300 tw-bg-background"
>
<div class="tw-max-w-screen-sm tw-mx-auto tw-flex tw-justify-between tw-w-full">
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-h-9">
<button
bitIconButton="bwi-back"
type="button"
*ngIf="showBackButton"
[title]="'back' | i18n"
[ariaLabel]="'back' | i18n"
></button>
<h1 bitTypography="h3" class="!tw-mb-0.5 tw-text-headers">{{ pageTitle }}</h1>
</div>
<div class="tw-inline-flex tw-items-center tw-gap-2 tw-h-9">
<ng-content select="[slot=end]"></ng-content>
</div>
</div>
</header>

View File

@ -0,0 +1,34 @@
import { BooleanInput, coerceBooleanProperty } from "@angular/cdk/coercion";
import { CommonModule, Location } from "@angular/common";
import { Component, Input } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { IconButtonModule, TypographyModule } from "@bitwarden/components";
@Component({
selector: "popup-header",
templateUrl: "popup-header.component.html",
standalone: true,
imports: [TypographyModule, CommonModule, IconButtonModule, JslibModule],
})
export class PopupHeaderComponent {
/** Display the back button, which uses Location.back() to go back one page in history */
@Input()
get showBackButton() {
return this._showBackButton;
}
set showBackButton(value: BooleanInput) {
this._showBackButton = coerceBooleanProperty(value);
}
private _showBackButton = false;
/** Title string that will be inserted as an h1 */
@Input({ required: true }) pageTitle: string;
constructor(private location: Location) {}
back() {
this.location.back();
}
}

View File

@ -0,0 +1,138 @@
import { Meta, Story, Canvas } from "@storybook/addon-docs";
import * as stories from "./popup-layout.stories";
<Meta of={stories} />
Please note that because these stories use `router-outlet`, there are issues with rendering content
when Light & Dark mode is selected. The stories are best viewed by selecting one color mode.
# Popup Tab Navigation
The popup tab navigation component composes together the popup page and the bottom tab navigation
footer. This component is intended to be used a level _above_ each extension tab's page code.
The navigation footer contains the 4 main page links for the browser extension. It uses the Angular
router to determine which page is currently active, and style the button appropriately. Clicking on
the buttons will navigate to the correct route. The navigation footer has a max-width built in so
that the page looks nice when the extension is popped out.
Long button names will be ellipsed.
Usage example:
```html
<popup-tab-navigation>
<popup-page></popup-page>
</popup-tab-navigation>
```
# Popup Page
The popup page handles positioning a page's `header` and `footer` elements, and inserting the rest
of the content into the `main` element with scroll. There is also a max-width built in so that the
page looks nice when the extension is popped out.
**Slots**
- `header`
- Use `popup-header` component.
- Every page should have a header.
- `footer`
- Use the `popup-footer` component.
- Not every page will have a footer.
- default
- Whatever content you want in `main`.
Basic usage example:
```html
<popup-page>
<popup-header slot="header"></popup-header>
<div>This is content</div>
<popup-footer slot="footer"></popup-footer>
</popup-page>
```
## Popup header
**Args**
- `pageTitle`: required
- Inserts title as an `h1`.
- `showBackButton`: optional, defaults to `false`
- Toggles the back button to appear. The back button uses `Location.back()` to navigate back one
page in history.
**Slots**
- `end`
- Use to insert one or more interactive elements.
- The header handles the spacing between elements passed to the `end` slot.
Usage example:
```html
<popup-header pageTitle="Test" showBackButton>
<ng-container slot="end">
<button></button>
<button></button>
</ng-container>
</popup-header>
```
Common interactive elements to insert into the `end` slot are:
- `app-current-account`: shows current account and switcher
- `app-pop-out`: shows popout button when the extension is not already popped out
- "Add" button: this can be accomplished with the Button component and any custom functionality for
that particular page
## Popup footer
Popup footer should be used when the page displays action buttons. It functions similarly to the
Dialog footer in that the calling code is responsible for passing in the different buttons that need
to be rendered.
Usage example:
```html
<popup-footer>
<button bitButton buttonType="primary">Save</button>
<button bitButton buttonType="secondary">Cancel</button>
</popup-footer>
```
# Page types
There are a few types of pages that are used in the browser extension.
View the story source code to see examples of how to construct these types of pages.
## Extension Tab
Example of wrapping an extension page in the `popup-tab-navigation` component.
<Canvas>
<Story of={stories.PopupTabNavigation} />
</Canvas>
## Extension Page
Examples of using just the `popup-page` component, without and with a footer.
<Canvas>
<Story of={stories.PopupPage} />
</Canvas>
<Canvas>
<Story of={stories.PopupPageWithFooter} />
</Canvas>
## Popped out
When the browser extension is popped out, the "popout" button should not be passed to the header.
<Canvas>
<Story of={stories.PoppedOut} />
</Canvas>

View File

@ -0,0 +1,367 @@
import { CommonModule } from "@angular/common";
import { Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
AvatarModule,
ButtonModule,
I18nMockService,
IconButtonModule,
} from "@bitwarden/components";
import { PopupFooterComponent } from "./popup-footer.component";
import { PopupHeaderComponent } from "./popup-header.component";
import { PopupPageComponent } from "./popup-page.component";
import { PopupTabNavigationComponent } from "./popup-tab-navigation.component";
@Component({
selector: "extension-container",
template: `
<div class="tw-h-[640px] tw-w-[380px] tw-border tw-border-solid tw-border-secondary-300">
<ng-content></ng-content>
</div>
`,
standalone: true,
})
class ExtensionContainerComponent {}
@Component({
selector: "vault-placeholder",
template: `
<div class="tw-mb-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item</div>
<div class="tw-my-8 tw-text-main">vault item last item</div>
`,
standalone: true,
})
class VaultComponent {}
@Component({
selector: "generator-placeholder",
template: ` <div class="tw-text-main">generator stuff here</div> `,
standalone: true,
})
class GeneratorComponent {}
@Component({
selector: "send-placeholder",
template: ` <div class="tw-text-main">send some stuff</div> `,
standalone: true,
})
class SendComponent {}
@Component({
selector: "settings-placeholder",
template: ` <div class="tw-text-main">change your settings</div> `,
standalone: true,
})
class SettingsComponent {}
@Component({
selector: "mock-add-button",
template: `
<button bitButton buttonType="primary" type="button">
<i class="bwi bwi-plus-f" aria-hidden="true"></i>
Add
</button>
`,
standalone: true,
imports: [ButtonModule],
})
class MockAddButtonComponent {}
@Component({
selector: "mock-popout-button",
template: `
<button
bitIconButton="bwi-popout"
size="small"
type="button"
title="Pop out"
aria-label="Pop out"
></button>
`,
standalone: true,
imports: [IconButtonModule],
})
class MockPopoutButtonComponent {}
@Component({
selector: "mock-current-account",
template: `
<button class="tw-bg-transparent tw-border-none" type="button">
<bit-avatar text="Ash Ketchum" size="small"></bit-avatar>
</button>
`,
standalone: true,
imports: [AvatarModule],
})
class MockCurrentAccountComponent {}
@Component({
selector: "mock-vault-page",
template: `
<popup-page>
<popup-header slot="header" pageTitle="Test">
<ng-container slot="end">
<mock-add-button></mock-add-button>
<mock-popout-button></mock-popout-button>
<mock-current-account></mock-current-account>
</ng-container>
</popup-header>
<vault-placeholder></vault-placeholder>
</popup-page>
`,
standalone: true,
imports: [
PopupPageComponent,
PopupHeaderComponent,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
VaultComponent,
],
})
class MockVaultPageComponent {}
@Component({
selector: "mock-vault-page-popped",
template: `
<popup-page>
<popup-header slot="header" pageTitle="Test">
<ng-container slot="end">
<mock-add-button></mock-add-button>
<mock-current-account></mock-current-account>
</ng-container>
</popup-header>
<vault-placeholder></vault-placeholder>
</popup-page>
`,
standalone: true,
imports: [
PopupPageComponent,
PopupHeaderComponent,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
VaultComponent,
],
})
class MockVaultPagePoppedComponent {}
@Component({
selector: "mock-generator-page",
template: `
<popup-page>
<popup-header slot="header" pageTitle="Test">
<ng-container slot="end">
<mock-add-button></mock-add-button>
<mock-popout-button></mock-popout-button>
<mock-current-account></mock-current-account>
</ng-container>
</popup-header>
<generator-placeholder></generator-placeholder>
</popup-page>
`,
standalone: true,
imports: [
PopupPageComponent,
PopupHeaderComponent,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
GeneratorComponent,
],
})
class MockGeneratorPageComponent {}
@Component({
selector: "mock-send-page",
template: `
<popup-page>
<popup-header slot="header" pageTitle="Test">
<ng-container slot="end">
<mock-add-button></mock-add-button>
<mock-popout-button></mock-popout-button>
<mock-current-account></mock-current-account>
</ng-container>
</popup-header>
<send-placeholder></send-placeholder>
</popup-page>
`,
standalone: true,
imports: [
PopupPageComponent,
PopupHeaderComponent,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
SendComponent,
],
})
class MockSendPageComponent {}
@Component({
selector: "mock-settings-page",
template: `
<popup-page>
<popup-header slot="header" pageTitle="Test">
<ng-container slot="end">
<mock-add-button></mock-add-button>
<mock-popout-button></mock-popout-button>
<mock-current-account></mock-current-account>
</ng-container>
</popup-header>
<settings-placeholder></settings-placeholder>
</popup-page>
`,
standalone: true,
imports: [
PopupPageComponent,
PopupHeaderComponent,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
SettingsComponent,
],
})
class MockSettingsPageComponent {}
@Component({
selector: "mock-vault-subpage",
template: `
<popup-page>
<popup-header slot="header" pageTitle="Test" showBackButton>
<ng-container slot="end">
<mock-popout-button></mock-popout-button>
</ng-container>
</popup-header>
<vault-placeholder></vault-placeholder>
<popup-footer slot="footer">
<button bitButton buttonType="primary">Save</button>
<button bitButton buttonType="secondary">Cancel</button>
</popup-footer>
</popup-page>
`,
standalone: true,
imports: [
PopupPageComponent,
PopupHeaderComponent,
PopupFooterComponent,
ButtonModule,
MockAddButtonComponent,
MockPopoutButtonComponent,
MockCurrentAccountComponent,
VaultComponent,
],
})
class MockVaultSubpageComponent {}
export default {
title: "Browser/Popup Layout",
component: PopupPageComponent,
decorators: [
moduleMetadata({
imports: [
PopupTabNavigationComponent,
CommonModule,
RouterModule,
ExtensionContainerComponent,
MockVaultSubpageComponent,
MockVaultPageComponent,
MockSendPageComponent,
MockGeneratorPageComponent,
MockSettingsPageComponent,
MockVaultPagePoppedComponent,
],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
back: "Back",
});
},
},
],
}),
applicationConfig({
providers: [
importProvidersFrom(
RouterModule.forRoot(
[
{ path: "", redirectTo: "vault", pathMatch: "full" },
{ path: "vault", component: MockVaultPageComponent },
{ path: "generator", component: MockGeneratorPageComponent },
{ path: "send", component: MockSendPageComponent },
{ path: "settings", component: MockSettingsPageComponent },
// in case you are coming from a story that also uses the router
{ path: "**", redirectTo: "vault" },
],
{ useHash: true },
),
),
],
}),
],
} as Meta;
type Story = StoryObj<PopupPageComponent>;
export const PopupTabNavigation: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<extension-container>
<popup-tab-navigation>
<router-outlet></router-outlet>
</popup-tab-navigation>
</extension-container>
`,
}),
};
export const PopupPage: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<extension-container>
<mock-vault-page></mock-vault-page>
</extension-container>
`,
}),
};
export const PopupPageWithFooter: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<extension-container>
<mock-vault-subpage></mock-vault-subpage>
</extension-container>
`,
}),
};
export const PoppedOut: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<div class="tw-h-[640px] tw-w-[900px] tw-border tw-border-solid tw-border-secondary-300">
<mock-vault-page-popped></mock-vault-page-popped>
</div>
`,
}),
};

View File

@ -0,0 +1,7 @@
<ng-content select="[slot=header]"></ng-content>
<main class="tw-bg-background-alt tw-p-3 tw-flex-1 tw-overflow-y-auto">
<div class="tw-max-w-screen-sm tw-mx-auto">
<ng-content></ng-content>
</div>
</main>
<ng-content select="[slot=footer]"></ng-content>

View File

@ -0,0 +1,11 @@
import { Component } from "@angular/core";
@Component({
selector: "popup-page",
templateUrl: "popup-page.component.html",
standalone: true,
host: {
class: "tw-h-full tw-flex tw-flex-col tw-flex-1 tw-overflow-y-auto",
},
})
export class PopupPageComponent {}

View File

@ -0,0 +1,32 @@
<div class="tw-h-full tw-overflow-y-auto [&>*]:tw-h-full [&>*]:tw-overflow-y-auto">
<ng-content></ng-content>
</div>
<footer class="tw-bg-background tw-border-0 tw-border-t tw-border-secondary-300 tw-border-solid">
<div class="tw-max-w-screen-sm tw-mx-auto">
<div class="tw-flex tw-flex-1">
<a
*ngFor="let button of navButtons"
class="tw-group tw-flex tw-flex-col tw-items-center tw-gap-1 tw-px-0.5 tw-pb-2 tw-pt-3 tw-w-1/4 tw-no-underline hover:tw-no-underline hover:tw-text-primary-600 hover:tw-bg-primary-100 tw-border-2 tw-border-solid tw-border-transparent focus-visible:tw-rounded-lg focus-visible:tw-border-primary-500"
[ngClass]="rla.isActive ? 'tw-font-bold tw-text-primary-600' : 'tw-text-muted'"
[title]="button.label"
[routerLink]="button.page"
routerLinkActive
#rla="routerLinkActive"
ariaCurrentWhenActive="page"
>
<i *ngIf="!rla.isActive" class="bwi bwi-lg bwi-{{ button.iconKey }}" aria-hidden="true"></i>
<i
*ngIf="rla.isActive"
class="bwi bwi-lg bwi-{{ button.iconKeyActive }}"
aria-hidden="true"
></i>
<span
class="tw-truncate tw-max-w-full"
[ngClass]="!rla.isActive && 'group-hover:tw-underline'"
>
{{ button.label }}
</span>
</a>
</div>
</div>
</footer>

View File

@ -0,0 +1,43 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { RouterModule } from "@angular/router";
import { LinkModule } from "@bitwarden/components";
@Component({
selector: "popup-tab-navigation",
templateUrl: "popup-tab-navigation.component.html",
standalone: true,
imports: [CommonModule, LinkModule, RouterModule],
host: {
class: "tw-block tw-h-full tw-w-full tw-flex tw-flex-col",
},
})
export class PopupTabNavigationComponent {
navButtons = [
{
label: "Vault",
page: "/vault",
iconKey: "lock",
iconKeyActive: "lock-f",
},
{
label: "Generator",
page: "/generator",
iconKey: "generate",
iconKeyActive: "generate-f",
},
{
label: "Send",
page: "/send",
iconKey: "send",
iconKeyActive: "send-f",
},
{
label: "Settings",
page: "/settings",
iconKey: "cog",
iconKeyActive: "cog-f",
},
];
}

View File

@ -36,6 +36,10 @@ import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { HeaderComponent } from "../platform/popup/header.component";
import { PopupFooterComponent } from "../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../platform/popup/layout/popup-page.component";
import { PopupTabNavigationComponent } from "../platform/popup/layout/popup-tab-navigation.component";
import { FilePopoutCalloutComponent } from "../tools/popup/components/file-popout-callout.component";
import { GeneratorComponent } from "../tools/popup/generator/generator.component";
import { PasswordGeneratorHistoryComponent } from "../tools/popup/generator/password-generator-history.component";
@ -108,6 +112,10 @@ import "../platform/popup/locales";
AccountComponent,
ButtonModule,
ExportScopeCalloutComponent,
PopupPageComponent,
PopupTabNavigationComponent,
PopupFooterComponent,
PopupHeaderComponent,
],
declarations: [
ActionButtonsComponent,

View File

@ -68,7 +68,7 @@ img {
border: none;
}
a {
a:not(popup-page a, popup-tab-navigation a) {
text-decoration: none;
@include themify($themes) {
@ -171,7 +171,7 @@ cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb,
}
}
header:not(bit-callout header, bit-dialog header) {
header:not(bit-callout header, bit-dialog header, popup-page header) {
height: 44px;
display: flex;
@ -448,7 +448,7 @@ app-root {
}
}
main {
main:not(popup-page main) {
position: absolute;
top: 44px;
bottom: 0;

View File

@ -2,6 +2,7 @@
"compilerOptions": {
"moduleResolution": "node",
"noImplicitAny": true,
"allowSyntheticDefaultImports": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "ES2020",

View File

@ -17,7 +17,11 @@
--color-background-alt3: 18 82 163;
--color-background-alt4: 13 60 119;
/* Can only be used behind the extension refresh flag */
--color-primary-100: 200 217 249;
--color-primary-300: 103 149 232;
/* Can only be used behind the extension refresh flag */
--color-primary-500: 23 93 220;
--color-primary-600: 23 93 220;
--color-primary-700: 18 82 163;
@ -43,6 +47,7 @@
--color-text-contrast: 255 255 255;
--color-text-alt2: 255 255 255;
--color-text-code: 192 17 118;
--color-text-headers: 2 15 102;
--tw-ring-offset-color: #ffffff;
}
@ -60,7 +65,9 @@
--color-background-alt3: 47 52 61;
--color-background-alt4: 16 18 21;
--color-primary-100: 8 31 73;
--color-primary-300: 23 93 220;
--color-primary-500: 54 117 232;
--color-primary-600: 106 153 240;
--color-primary-700: 180 204 249;
@ -86,6 +93,7 @@
--color-text-contrast: 25 30 38;
--color-text-alt2: 255 255 255;
--color-text-code: 240 141 199;
--color-text-headers: 226 227 228;
--tw-ring-offset-color: #1f242e;
}

View File

@ -24,7 +24,11 @@ module.exports = {
current: colors.current,
black: colors.black,
primary: {
// Can only be used behind the extension refresh flag
100: rgba("--color-primary-100"),
300: rgba("--color-primary-300"),
// Can only be used behind the extension refresh flag
500: rgba("--color-primary-500"),
600: rgba("--color-primary-600"),
700: rgba("--color-primary-700"),
},
@ -69,6 +73,7 @@ module.exports = {
main: rgba("--color-text-main"),
muted: rgba("--color-text-muted"),
contrast: rgba("--color-text-contrast"),
headers: rgba("--color-text-headers"),
alt2: rgba("--color-text-alt2"),
code: rgba("--color-text-code"),
success: rgba("--color-success-600"),

View File

@ -5,6 +5,7 @@ config.content = [
"libs/components/src/**/*.{html,ts,mdx}",
"libs/auth/src/**/*.{html,ts,mdx}",
"apps/web/src/**/*.{html,ts,mdx}",
"apps/browser/src/**/*.{html,ts,mdx}",
"bitwarden_license/bit-web/src/**/*.{html,ts,mdx}",
".storybook/preview.tsx",
];

View File

@ -5,7 +5,7 @@
"noImplicitAny": true,
"target": "ES2016",
"module": "ES2020",
"lib": ["es5", "es6", "es7", "dom"],
"lib": ["es5", "es6", "es7", "dom", "ES2021"],
"sourceMap": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
@ -38,6 +38,16 @@
],
"useDefineForClassFields": false
},
"include": ["apps/web/src/**/*", "libs/*/src/**/*", "bitwarden_license/bit-web/src/**/*"],
"exclude": ["apps/web/src/**/*.spec.ts", "libs/*/src/**/*.spec.ts", "**/*.spec-util.ts"]
"include": [
"apps/web/src/**/*",
"apps/browser/src/**/*",
"libs/*/src/**/*",
"bitwarden_license/bit-web/src/**/*"
],
"exclude": [
"apps/web/src/**/*.spec.ts",
"apps/browser/src/**/*.spec.ts",
"libs/*/src/**/*.spec.ts",
"**/*.spec-util.ts"
]
}