Tuesday, 4 January 2022

CSS Underlines Are Too Thin and Too Low in Chrome

I’ve encountered two bugs in Chrome while testing the new CSS text-decoration-thickness and text-underline-offset properties, and I want to share them with you here in this article.

But first, let’s acknowledge one thing:

Default underlines are inconsistent

Let’s add a text link to a plain web page, set its font-family to Arial, and compare the underlines across browsers and operating systems.

From left to right: Chrome, Safari, and Firefox on macOS; Safari on iOS; Chrome, and Firefox on Windows; Chrome, and Firefox on Android.

As you can see, the default underline is inconsistent across browsers. Each browser chooses their own default thickness and vertical position (offset from the baseline) for the underline. This is in line with the CSS Text Decoration module, which specifies the following default behavior (auto value):

The user agent chooses an appropriate thickness for text decoration lines. […] The user agent chooses an appropriate offset for underlines.

Luckily, we can override the browsers’ defaults

There are two new, widely supported CSS properties that allow us to precisely define the thickness and offset for our underlines:

With these properties, we can create consistent underlines even across two very different browsers, such as the Gecko-based Firefox on Android and the WebKit-based Safari on macOS.

h1 {
  text-decoration: underline;
  text-decoration-thickness: 0.04em;
  text-underline-offset: 0.03em;
}
Top row: the browsers’ default underlines; bottom row: consistent underlines with CSS. (Demo)

Note: The text-decoration-thickness property also has a special from-font value that instructs browsers to use the font’s own preferred underline width, if available. I tested this value with a few different fonts, but the underlines were inconsistent.

OK, so let’s move on to the two Chrome bugs I noted earlier.

Chrome bug 1: Underlines are too thin on macOS

If you set the text-decoration-thickness property to a font-relative length value that computes to a non-integer pixel value, Chrome will “floor” that value instead of rounding it to the nearest integer. For example, if the declared thickness is 0.06em, and that computes to 1.92px, Chrome will paint a thickness of 1px instead of 2px. This issue is limited to macOS.

a {
  font-size: 2em; /* computes to 32px */
  text-decoration-thickness: 0.06em; /* computes to 1.92px */
}

In the following screenshot, notice how the text decoration lines are twice as thin in Chrome (third row) than in Safari and Firefox.

From top to bottom: Safari, Firefox, and Chrome on macOS. (Demo)

For more information about this bug, see Chromium issue #1255280.

Chrome bug 2: Underlines are too low

The text-underline-offset property allows us to precisely set the distance between the alphabetic baseline and the underline (the underline’s offset from the baseline). Unfortunately, this feature is currently not implemented correctly in Chrome and, as a result, the underline is positioned too low.

h1 {
  text-decoration: underline;
  text-decoration-color: #f707;

  /* disable “skip ink” */
  -webkit-text-decoration-skip: none; /* Safari */
  text-decoration-skip-ink: none;

  /* cover the entire descender */
  text-decoration-thickness: 0.175em; /* descender height */
  text-underline-offset: 0; /* no offset from baseline */
}

Because of this bug, it is not possible to move the underline all the way up to the baseline in Chrome.

From left to right: Safari, Firefox, and Chrome on macOS. View this demo on CodePen.

For more information about this bug, see Chromium issue #1172623.

Note: As you might have noticed from the image above, Safari draws underlines on top of descenders instead of beneath them. This is a WebKit bug that was fixed very recently. The fix should ship in the next version of Safari.

Help prioritize the Chrome bugs

The two new CSS properties for styling underlines are a welcome addition to CSS. Hopefully, the two related Chrome bugs will be fixed sooner rather than later. If these CSS features are important to you, make your voice heard by starring the bugs in Chromium’s bug tracker.

Sign in with your Google account and click the star button on issues #1172623 and #1255280.

CSS Underlines Are Too Thin and Too Low in Chrome originally published on CSS-Tricks



from CSS-Tricks https://ift.tt/3eLRTnH
via IFTTT

Jetpack Features We Love and Use at CSS-Tricks

(This is a sponsored post.)

We use and love Jetpack around here. It’s a WordPress plugin that brings a whole suite of functionality to your site ranging from security to marketing with lots of ridiculously useful stuff in between! Here’s our favorite features around here.

Jetpack’s Search feature gives your site an incredibly powerful search engine with the flip of a switch. You get a very fast, truly intelligent search for your entire site that is easily sortable and filterable with literally zero work on your part. You can’t rely on default WordPress search — this is a must-have. Bonus: it’s all handled offsite, so there is minimal burden on your server.

Showing the full-page JetPack search feature.

Backups & Activity

We sleep easy knowing CSS-Tricks is entirely backed up in real-time. Everything is backed up from the site’s content, comments, settings, theme files, media, even WordPress itself.

An activity log shows off everything that happens on the site, and I use that same log to restore the site to any point in time.

Showing a timeline log of site changes in Jetpack Backup.

Performant Media

There are at least four things you have to do with images on websites to make sure you’re serving them in a performance responsible way: (1) use the responsive images syntax to serve an appropriately sized version, (2) optimize the image, (3) lazy load the image, and (4) serve the image from a CDN. Fortunately, WordPress itself helps with #1, which can be tricky. Jetpack helps with the others with the flip of a switch.

And don’t forget about video! VideoPress does even more for your hosted videos. No ads, beautiful feature-rich player, CDN-hosted optimized video, poster graphics for mobile, and you do absolutely nothing different with your workflow: just drag and drop videos into posts.

JetPack Markdown

Writing content in Markdown can be awful handy. Especially on a developer-focused site, it makes sense to offer it to users in the comment section.

With Jetpack Markdown, you also get a Markdown block to use in the block editor so you can write in chunks of Markdown wherever needed.

CSS-Tricks has thousands of pages of content! It’s a challenge for us to surface all the best stuff, particularly on a per-topic basis and without having to hand-pick everything. Showing related posts is tricky to pull off and we love that Jetpack does a great job with it, all without burdening our servers the way other related content solutions can.

Social Connections

We like to tell the world as best as we can when we publish new content. Rather than having to do that manually, we can share the post to Twitter and Facebook the second we hit that “Publish” button. You can always head back to older content and re-publish to social media as well.

Showing the Jetpack publishing settings prior to publishing the post. It shows options to publish to Twitter and Facebook and an area to customize the message.

This isn’t a complete list. The official features page will show you even more. Every site’s needs will be different. There are all sorts of security, design, and promotion features that might be your favorites. If you manage a lot of WordPress sites, as agencies often too, take note there is a new Licensing Portal to manage billing across multiple sites much more easily.


Jetpack Features We Love and Use at CSS-Tricks originally published on CSS-Tricks



from CSS-Tricks https://ift.tt/3pQOnP9
via IFTTT

Monday, 3 January 2022

CSS-Based Fingerprinting

Fingerprinting is bad. It’s a term that refers to building up enough metadata about a user that you can essentially figure out who they are. JavaScript has access to all sorts of fingerprinting possibilities, which then combined with the IP address that the server has access to, means fingerprinting is all too common.

You don’t generally think of CSS as being a fingerprinting vector though, and thus “safe” in that way. But Oliver Brotchie has documented an idea that allows for some degree of fingerprinting with CSS alone.

Think of all the @media queries we have. We can test for pointer type with any-pointer. Imagine that for each value, we request a totally unique background-image from a server. If that image was requested, we know those @media queries were true. We can start to fingerprint with something like this:

.pointer {
  background-image: url('/unique-id/pointer=none')
}

@media (any-pointer: coarse) {
  .pointer {
    background-image: url('/unique-id/pointer=coarse')
  }
}

@media (any-pointer: fine) {
  .pointer {
    background-image: url('/unique-id/pointer=fine')
  }
}

Combine that with the fact that we can test for a dark mode preference with prefers-color-scheme, the fingerprint gets a bit clearer. In fact, it’s the current draft for CSS user prefer media queries that Oliver is most concerned about:

Not only will the upcoming draft make this method scalable, but it will also increase its precision. Currently, without alternative means, it is hard to conclusively link every request to a specific visitor as the only feasible way to determine their origin, is to group the requests by the IP address of the connection. However, with the new draft, by generating a randomised string and interpolating it into the URL tag for every visitor, we can accurately identify all requests from said visitor.

There are tons more. We can make media queries that are 1px apart and request a background image for each, perfectly guessing the visitor’s window size. There are probably a dozen or more exotic media queries that are rarely used, but are useful specifically to fingerprinting with CSS. Combine that with @supports queries for all sorts of things to essentially guess the exact browser. And combine that with the classic technique of testing for installation of specific local fonts, and you have a half-decent fingerprinting machine.

@font-face
  font-family: 'some-font'
  src: local(some font), url('some/url/some-font')

.some-font
  font-family:'some-font'

The generated CSS to do it is massive (here’s the Sass to generate it), but apparently it’s heavily reduced once we can use custom properties in URLs.

I’m not heavily worried about it, mostly because I don’t disable JavaScript and JavaScript is so much more widely capable of fingerprinting already. Plus, there are already other types of CSS security vulnerabilities, from reading visited links (which browsers have addressed), keylogging, and user generated inline styles, among others that folks have pointed out in another article on the topic.

But Oliver’s research on fingerprinting is really good and worthy of a look by everyone who knows more about web security than I do.


CSS-Based Fingerprinting originally published on CSS-Tricks



from CSS-Tricks https://ift.tt/3JA4usa
via IFTTT

How to Create a Browser Extension

I’ll bet you are using browser extensions right now. Some of them are extremely popular and useful, like ad blockers, password managers, and PDF viewers. These extensions (or “add-ons”) are not limited to those purposes — you can do a lot more with them! In this article, I will give you an introduction on how to create one. Ultimately, we’ll make it work in multiple browsers.

What we’re making

We’re making an extension called “Transcribers of Reddit” and it’s going to improve Reddit’s accessibility by moving specific comments to the top of the comment section and adding aria- attributes for screen readers. We will also take our extension a little further with options for adding borders and backgrounds to comments for better text contrast.

The whole idea is that you’ll get a nice introduction for how to develop a browser extension. We will start by creating the extension for Chromium-based browsers (e.g. Google Chrome, Microsoft Edge, Brave, etc.). In a future post we will port the extension to work with Firefox, as well as Safari which recently added support for Web Extensions in both the MacOS and iOS versions of the browser.

Ready? Let’s take this one step at a time.

Create a working directory

Before anything else, we need a working space for our project. All we really need is to create a folder and give it a name (which I’m calling transcribers-of-reddit). Then, create another folder inside that one named src for our source code.

Define the entry point

The entry point is a file that contains general information about the extension (i.e. extension name, description, etc.) and defines permissions or scripts to execute.

Our entry point can be a manifest.json file located in the src folder we just created. In it, let’s add the following three properties:

{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0"
}

The manifest_version is similar to version in npm or Node. It defines what APIs are available (or not). We’re going to work on the bleeding edge and use the latest version, 3 (also known as as mv3).

The second property is name and it specifies our extension name. This name is what’s displayed everywhere our extension appears, like Chrome Web Store and the chrome://extensions page in the Chrome browser.

Then there’s version. It labels the extension with a version number. Keep in mind that this property (in contrast to manifest_version) is a string that can only contain numbers and dots (e.g. 1.3.5).

More manifest.json information

There’s actually a lot more we can add to help add context to our extension. For example, we can provide a description that explains what the extension does. It’s a good idea to provide these sorts of things, as it gives users a better idea of what they’re getting into when they use it.

In this case, we’re not only adding a description, but supplying icons and a web address that Chrome Web Store points to on the extension’s page.

{
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/"
}
  • The description is displayed on Chrome’s management page (chrome://extensions) and should be brief, less than 132 characters.
  • The icons are used in lots of places. As the docs state, it’s best to provide three versions of the same icon in different resolutions, preferably as a PNG file. Feel free to use the ones in the GitHub repository for this example.
  • The homepage_url can be used to connect your website with the extension. A button including the link will be displayed when clicking on “More details” on the management page.
Our opened extension card inside the extension management page.

Setting permissions

One major advantage extensions have is that their APIs allow you to interact directly with the browser. But we have to explicitly give the extension those permissions, which also goes inside the manifest.json file.


{
  "manifest_version": 3,
  "name": "Transcribers of Reddit",
  "version": "1.0",
  "description": "Reddit made accessible for disabled users.",
  "icons": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  },
  "homepage_url": "https://lars.koelker.dev/extensions/tor/",

  "permissions": [
    "storage",
    "webNavigation"
  ]
}

What did we just give this extension permission to? First, storage. We want this extension to be able to save the user’s settings, so we need to access the browser’s web storage to hold them. For example, if the user wants red borders on the comments, then we’ll save that for next time rather than making them set it again.

We also gave the extension permission to look at how the user navigated to the current screen. Reddit is a single-page application (SPA) which means it doesn’t trigger a page refresh. We need to “catch” this interaction, as Reddit will only load the comments of a post if we click on it. So, that’s why we’re tapping into webNavigation.

We’ll get to executing code on a page later as it requires a whole new entry inside manifest.json.

/explanation Depending on which permissions are allowed, the browser might display a warning to the user to accept the permissions. It’s only certain ones, though, and Chrome has a nice outline of them.

Managing translations

Browser extensions have a built-in internalization (i18n) API. It allows you to manage translations for multiple languages (full list). To use the API, we have to define our translations and default language right in the manifest.json file:

"default_locale": "en"

This sets English as the language. In the event that a browser is set to any other language that isn’t supported, the extension will fall back to the default locale (en in this example).

Our translations are defined inside the _locales directory. Let’s create another folder in there each language you want to support. Each subdirectory gets its own messages.json file.

src 
 └─ _locales
     └─ en
        └─ messages.json
     └─ fr
        └─ messages.json

A translation file consists of multiple parts:

  • Translation key (“id”): This key is used to reference the translation.
  • Message: The actual translation content
  • Description (optional): Describes the translation (I wouldn’t use them, they just bloat up the file and your translation key should be descriptive enough)
  • Placeholders (optional): Can be used to insert dynamic content inside a translation

Here’s an example that pulls all that together:

{
  "userGreeting": { // Translation key ("id")
    "message": "Good $daytime$, $user$!" // Translation
    "description": "User Greeting", // Optional description for translators
    "placeholders": { // Optional placeholders
      "daytime": { // As referenced inside the message
        "content": "$1",
        "example": "morning" // Example value for our content
      },
      "user": { 
        "content": "$1",
        "example": "Lars"
      }
    }
  }
}

Using placeholders is a bit more challenging. At first we need to define the placeholder inside the message. A placeholder needs to be wrapped in between $ characters. Afterwards, we have to add our placeholder to the “placeholder list.” This is a bit unintuitive, but Chrome wants to know what value should be inserted for our placeholders. We (obviously) want to use a dynamic value here, so we use the special content value $1 which references our inserted value.

The example property is optional. It can be used to give translators a hint what value the placeholder could be (but is not actually displayed).

We need to define the following translations for our extension. Copy and paste them into the messages.json file. Feel free to add more languages (e.g. if you speak German, add a de folder inside _locales, and so on).

{
  "name": {
    "message": "Transcribers of Reddit"
  },
  "description": {
    "message": "Accessible image descriptions for subreddits."
  },
  "popupManageSettings": {
    "message": "Manage settings"
  },
  "optionsPageTitle": {
    "message": "Settings"
  },
  "sectionGeneral": {
    "message": "General settings"
  },
  "settingBorder": {
    "message": "Show comment border"
  },
  "settingBackground": {
    "message": "Show comment background"
  }
}

You might be wondering why we registered the permissions when there is no sign of an i18n permission, right? Chrome is a bit weird in that regard, as you don’t need to register every permission. Some (e.g. chrome.i18n) don’t require an entry inside the manifest. Other permissions require an entry but won’t be displayed to the user when installing the extension. Some other permissions are “hybrid” (e.g. chrome.runtime), meaning some of their functions can be used without declaring a permission—but other functions of the same API require one entry in the manifest. You’ll want to take a look at the documentation for a solid overview of the differences.

Using translations inside the manifest

The first thing our end user will see is either the entry inside the Chrome Web Store or the extension overview page. We need to adjust our manifest file to make sure everything os translated.

{
  // Update these entries
  "name": "__MSG_name__",
  "description": "__MSG_description__"
}

Applying this syntax uses the corresponding translation in our messages.json file (e.g. _MSG_name_ uses the name translation).

Using translations in HTML pages

Applying translations in an HTML file takes a little JavaScript.

chrome.i18n.getMessage('name');

That code returns our defined translation (which is Transcribers of Reddit). Placeholders can be done in a similar way.

chrome.i18n.getMessage('userGreeting', {
  daytime: 'morning',
  user: 'Lars'
});

It would be a pain in the butt to apply translations to all elements this way. But we can write a little script that performs the translation based on a data- attribute. So, let’s create a new js folder inside the src directory, then add a new util.js file in it.

src 
 └─ js
     └─ util.js

This gets the job done:

const i18n = document.querySelectorAll("[data-intl]");
i18n.forEach(msg => {
  msg.innerHTML = chrome.i18n.getMessage(msg.dataset.intl);
});

chrome.i18n.getAcceptLanguages(languages => {
  document.documentElement.lang = languages[0];
});

Once that script is added to an HTML page, we can add the data-intl attribute to an element to set its content. The document language will also be set based on the user language.

<!-- Before JS execution -->
<html>
  <body>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>
<!-- After JS execution -->
<html lang="en">
  <body>
    <button data-intl="popupManageSettings">Manage settings</button>
  </body>
</html>

Adding a pop-up and options page

Before we dive into actual programming, we we need to create two pages:

  1. An options page that contains user settings
  2. A pop-up page that opens when interacting with the extension icon right next to our address bar. This page can be used for various scenarios (e.g. for displaying stats or quick settings).
The options page containg our settings.
The pop-up containg a link to the options page.

Here’s an outline of the folders and files we need in order to make the pages:

src 
 ├─ css
 |    └─ paintBucket.css
 ├─ popup
 |    ├─ popup.html
 |    ├─ popup.css
 |    └─ popup.js
 └─ options
      ├─ options.html
      ├─ options.css
      └─ options.js

The .css files contain plain CSS, nothing more and nothing less. I won’t into detail because I know most of you reading this are already fully aware of how CSS works. You can copy and paste the styles from the GitHub repository for this project.

Note that the pop-up is not a tab and that its size depends on the content in it. If you want to use a fixed popup size, you can set the width and height properties on the html element.

Creating the pop-up

Here’s an HTML skeleton that links up the CSS and JavaScript files and adds a headline and button inside the <body>.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title data-intl="name"></title>

    <link rel="stylesheet" href="../css/paintBucket.css">
    <link rel="stylesheet" href="popup.css">

    <!-- Our "translation" script -->
    <script src="../js/util.js" defer></script>
    <script src="popup.js" defer></script>
  </head>
  <body>
    <h1 id="title"></h1>
    <button data-intl="popupManageSettings"></button>
  </body>
</html>

The h1 contains the extension name and version; the button is used to open the options page. The headline will not be filled with a translation (because it lacks a data-intl attribute), and the button doesn’t have any click handler yet, so we need to populate our popup.js file:

const title = document.getElementById('title');
const settingsBtn = document.querySelector('button');
const manifest = chrome.runtime.getManifest();

title.textContent = `${manifest.name} (${manifest.version})`;

settingsBtn.addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

This script first looks for the manifest file. Chrome offers the runtime API which contains the getManifest method (this specific method does not require the runtime permission). It returns our manifest.json as a JSON object. After we populate the title with the extension name and version, we can add an event listener to the settings button. If the user interacts with it, we will open the options page using chrome.runtime.openOptionsPage() (again no permission entry needed).

The pop-up page is now finished, but the extension doesn’t know it exists yet. We have to register the pop-up by appending the following property to the manifest.json file.

"action": {
  "default_popup": "popup/popup.html",
  "default_icon": {
    "16": "images/logo/16.png",
    "48": "images/logo/48.png",
    "128": "images/logo/128.png"
  }
},

Creating the options page

Creating this page follows a pretty similar process as what we just completed. First, we populate our options.html file. Here’s some markup we can use:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title data-intl="name"></title>

  <link rel="stylesheet" href="../css/paintBucket.css">
  <link rel="stylesheet" href="options.css">

  <!-- Our "translation" script -->
  <script src="../js/util.js" defer></script>
  <script src="options.js" defer></script>
</head>
<body>
  <header>
    <h1>
      <!-- Icon provided by feathericons.com -->
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" role="presentation">
        <circle cx="12" cy="12" r="3"></circle>
        <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
      </svg>
      <span data-intl="optionsPageTitle"></span>
    </h1>
  </header>

  <main>
    <section id="generalOptions">
      <h2 data-intl="sectionGeneral"></h2>

      <div id="generalOptionsWrapper"></div>
    </section>
  </main>

  <footer>
    <p>Transcribers of Reddit extension by <a href="https://lars.koelker.dev" target="_blank">lars.koelker.dev</a>.</p>
    <p>Reddit is a registered trademark of Reddit, Inc. This extension is not endorsed or affiliated with Reddit, Inc. in any way.</p>
  </footer>
</body>
</html>

There are no actual options yet (just their wrappers). We need to write the script for the options page. First, we define variables to access our wrappers and default settings inside options.js. “Freezing” our default settings prevents us from accidentally modifying them later.

const defaultSettings = Object.freeze({
  border: false,
  background: false,
});
const generalSection = document.getElementById('generalOptionsWrapper');

Next, we need to load the saved settings. We can use the (previously registered) storage API for that. Specifically, we need to define if we want to store the data locally (chrome.storage.local) or sync settings through all devices the end user is logged in to (chrome.storage.sync). Let’s go with local storage for this project.

Retrieving values needs to be done with the get method. It accepts two arguments:

  1. The entries we want to load
  2. A callback containing the values

Our entries can either be a string (e.g. like settings below) or an array of entries (useful if we want to load multiple entries). The argument inside the callback function contains an object of all entries we previously defined in { settings: ... }:

chrome.storage.local.get('settings', ({ settings }) => {
  const options = settings ?? defaultSettings; // Fall back to default if settings are not defined
  if (!settings) {
    chrome.storage.local.set({
     settings: defaultSettings,
    });
 }

  // Create and display options
  const generalOptions = Object.keys(options).filter(x => !x.startsWith('advanced'));
  
  generalOptions.forEach(option => createOption(option, options, generalSection));
});

To render the options, we also need to create a createOption() function.

function createOption(setting, settingsObject, wrapper) {
  const settingWrapper = document.createElement("div");
  settingWrapper.classList.add("setting-item");
  settingWrapper.innerHTML = `
  <div class="label-wrapper">
    <label for="${setting}" id="${setting}Desc">
      ${chrome.i18n.getMessage(`setting${setting}`)}
    </label>
  </div>

  <input type="checkbox" ${settingsObject[setting] ? 'checked' : ''} id="${setting}" />
  <label for="${setting}"
    tabindex="0"
    role="switch"
    aria-checked="${settingsObject[setting]}"
    aria-describedby="${setting}-desc"
    class="is-switch"
  ></label>
  `;

  const toggleSwitch = settingWrapper.querySelector("label.is-switch");
  const input = settingWrapper.querySelector("input");

  input.onchange = () => {
    toggleSwitch.setAttribute('aria-checked', input.checked);
    updateSetting(setting, input.checked);
  };

  toggleSwitch.onkeydown = e => {
    if(e.key === " " || e.key === "Enter") {
      e.preventDefault();
      toggleSwitch.click();
    }
  }

  wrapper.appendChild(settingWrapper);
}

Inside the onchange event listener of our switch (aká radio button) we call the function updateSetting. This method will write the updated value of our radio button inside the storage.

To accomplish this, we will make use of the set function. It has two arguments: The entry we want to overwrite and an (optional) callback (which we don’t use in our case). As our settings entry is not a boolean or a string but an object containing different settings, we use the spread operator () and only overwrite our actual key (setting) inside the settings object.

function updateSetting(key, value) {
  chrome.storage.local.get('settings', ({ settings }) => {
    chrome.storage.local.set({
      settings: {
        ...settings,
        [key]: value
      }
    })
  });
}

Once again, we need to “inform” the extension about our options page by appending the following entry to the manifest.json:

"options_ui": {
  "open_in_tab": true,
  "page": "options/options.html"
},

Depending on your use case you can also force the options dialog to open as a popup by setting open_in_tab to false.

Installing the extension for development

Now that we’ve successfully set up the manifest file and have added both the pop-up and options page to the mix, we can install our extension to check if our pages actually work. Navigate to chrome://extensions and enable “Developer mode.” Three buttons will appear. Click the one labeled “Load unpacked” and select the src folder of your extension to load it up.

The extension should now be successfully installed and our “Transcribers of Reddit” tile should be on the page.

We can already interact with our extension. Click on the puzzle piece (🧩) icon right next to the browser’s address bar and click on the newly-added “Transcribers of Reddit” extension. You should now be greeted by a small pop-up with the button to open the options page.

Lovely, right? It might look a bit different on your device, as I have dark mode enabled in these screenshots.

If you enable the “Show comment background” and “Show comment border” settings, then reload the page, the state will persist because we’re saving it in the browser’s local storage.

Adding the content script

OK, so we can already trigger the pop-up and interact with the extension settings, but the extension doesn’t do anything particularly useful yet. To give it some life, we will add a content script.

Add a file called comment.js inside the js directory and make sure to define it in the manifest.json file:

"content_scripts": [
  {
    "matches": [ "*://www.reddit.com/*" ],
    "js": [ "js/comment.js" ]
  }
],

The content_scripts is made up of two parts:

  • matches: This array holds URLs that tell the browser where we want our content scripts to run. Being an extension for Reddit and all, we want this to run on any page matching ://www.redit.com/*, where the asterisk is a wild card to match anything after the top-level domain.
  • js: This array contains the actual content scripts.

Content scripts can’t interact with other (normal) JavaScripts. This means if a website’s scripts defines a variable or function, we can’t access it. For example:

// script_on_website.js
const username = 'Lars';

// content_script.js
console.log(username); // Error: username is not defined

Now let’s start writing our content script. First, we add some constants to comment.js. These constants contain RegEx expressions and selectors that will be used later on. The CommentUtils is used to determine whether or not a post contains a “tor comment,” or if comment wrappers exists.

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const Selectors = Object.freeze({
  commentWrapper: 'div[style*="--commentswrapper-gradient-color"] > div, div[style*="max-height: unset"] > div',
  torComment: 'div[data-tor-comment]',
  postContent: 'div[data-test-id="post-content"]'
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const CommentUtils = Object.freeze({
  isTorComment: (comment) => comment.querySelector('[data-test-id="comment"]') ? comment.querySelector('[data-test-id="comment"]').textContent.includes('m a human volunteer content transcriber for Reddit') : false,
  torCommentsExist: () => !!document.querySelector(Selectors.torComment),
  commentWrapperExists: () => !!document.querySelector('[data-reddit-comment-wrapper="true"]')
});

Next, we check whether or not a user directly opens a comment page (“post”), then perform a RegEx check and update the directPage variable. This case occurs when a user directly opens the URL (e.g. by typing it into the address bar or clicking on<a> element on another page, like Twitter).

let directPage = false;
if (UrlRegex.commentPage.test(window.location.href)) {
  directPage = true;
  moveComments();
}

Besides opening a page directly, a user normally interacts with the SPA. To catch this case, we can add a message listener to our comment.js file by using the runtime API.

chrome.runtime.onMessage.addListener(msg => {
  if (msg.type === messageTypes.COMMENT_PAGE) {
    waitForComment(moveComments);
  }
});

All we need now are the functions. Let’s create a moveComments() function. It moves the special “tor comment” to the start of the comment section. It also conditionally applies a background color and border (if borders are enabled in the settings) to the comment. For this, we call the storage API and load the settings entry:

function moveComments() {
  if (CommentUtils.commentWrapperExists()) {
    return;
  }

  const wrapper = document.querySelector(Selectors.commentWrapper);
  let comments = wrapper.querySelectorAll(`${Selectors.commentWrapper} > div`);
  const postContent = document.querySelector(Selectors.postContent);

  wrapper.dataset.redditCommentWrapper = 'true';
  wrapper.style.flexDirection = 'column';
  wrapper.style.display = 'flex';

  if (directPage) {
    comments = document.querySelectorAll("[data-reddit-comment-wrapper='true'] > div");
  }

  chrome.storage.local.get('settings', ({ settings }) => { // HIGHLIGHT 18
    comments.forEach(comment => {
      if (CommentUtils.isTorComment(comment)) {
        comment.dataset.torComment = 'true';
        if (settings.background) {
          comment.style.backgroundColor = 'var(--newCommunityTheme-buttonAlpha05)';
        }
        if (settings.border) {
          comment.style.outline = '2px solid red';
        }
        comment.style.order = "-1";
        applyWaiAria(postContent, comment);
      }
    });
  })
}

The applyWaiAria() function is called inside the moveComments() function—it adds aria- attributes. The other function creates a unique identifier for use with the aria- attributes.

function applyWaiAria(postContent, comment) {
  const postMedia = postContent.querySelector('img[class*="ImageBox-image"], video');
  const commentId = uuidv4();

  if (!postMedia) {
    return;
  }

  comment.setAttribute('id', commentId);
  postMedia.setAttribute('aria-describedby', commentId);
}

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
    return v.toString(16);
  });
}

The following function waits for the comments to load and calls the callback parameter if it finds the comment wrapper.

function waitForComment(callback) {
  const config = { childList: true, subtree: true };
  const observer = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      if (document.querySelector(Selectors.commentWrapper)) {
        callback();
        observer.disconnect();
        clearTimeout(timeout);
        break;
      }
    }
  });

  observer.observe(document.documentElement, config);
  const timeout = startObservingTimeout(observer, 10);
}

function startObservingTimeout(observer, seconds) {
  return setTimeout(() => {
    observer.disconnect();
  }, 1000 * seconds);
}

Adding a service worker

Remember when we added a listener for messages inside the content script? This listener isn’t currently receiving messages. We need to send it to the content script ourselves. For this purpose we need to register a service worker.

We have to register our service worker inside the manifest.json by appending the following code to it:

"background": {
  "service_worker": "sw.js"
}

Don’t forget to create the sw.js file inside the src directory (service workers always need to be created in the extension’s root directory, src.

Now, let’s create some constants for the message and page types:

const messageTypes = Object.freeze({
  COMMENT_PAGE: 'comment_page',
  SUBREDDIT_PAGE: 'subreddit_page',
  MAIN_PAGE: 'main_page',
  OTHER_PAGE: 'other_page',
});

const UrlRegex = Object.freeze({
  commentPage: /\/r\/.*\/comments\/.*/,
  subredditPage: /\/r\/.*\//
});

const Utils = Object.freeze({
  getPageType: (url) => {
    if (new URL(url).pathname === '/') {
      return messageTypes.MAIN_PAGE;
    } else if (UrlRegex.commentPage.test(url)) {
      return messageTypes.COMMENT_PAGE;
    } else if (UrlRegex.subredditPage.test(url)) {
      return messageTypes.SUBREDDIT_PAGE;
    }

    return messageTypes.OTHER_PAGE;
  }
});

We can add the service worker’s actual content. We do this with an event listener on the history state (onHistoryStateUpdated) that detects when a page has been updated with the History API (which is commonly used in SPAs to navigate without a page refresh). Inside this listener, we query the active tab and extract its tabId. Then we send a message to our content script containing the page type and URL.

chrome.webNavigation.onHistoryStateUpdated.addListener(async ({ url }) => {
  const [{ id: tabId }] = await chrome.tabs.query({ active: true, currentWindow: true });

  chrome.tabs.sendMessage(tabId, {
    type: Utils.getPageType(url),
    url
  });
});

All done!

We’re finished! Navigate to Chrome’s extension management page ( chrome://extensions) and hit the reload icon on the unpacked extension. If you open a Reddit post that contains a “Transcribers of Reddit” comment with an image transcription (like this one), it will be moved to the start of the comment section and be highlighted as long as we’ve enabled it in the extension settings.

The “Transcribers of Reddit” extension highlights a particular comment by moving it to the top of the Reddit thread’s comment list and giving it a bright red border

Conclusion

Was that as hard as you thought it would be? It’s definitely a lot more straightforward than I thought before digging in. After setting up the manifest.json and creating any page files and assets we need, all we’re really doing is writing HTML, CSS, and JavaScript like normal.

If you ever find yourself stuck along the way, the Chrome API documentation is a great resource to get back on track.

Once again, here’s the GitHub repo with all of the code we walked through in this article. Read it, use it, and let me know what you think of it!


How to Create a Browser Extension originally published on CSS-Tricks



from CSS-Tricks https://ift.tt/3pR3lo4
via IFTTT

Saturday, 1 January 2022

Thank You (2021 Edition)

The year has come to a close and it’s time again for our end-of-year wrapup. The most important message is this: thank you. (thankyouthankyou)

Thanks for stopping by and reading this site. If you didn’t, I’d be out of a job around here, and I quite like this job so I owe it all to you. Like a family holiday card, allow me to share how the year went from our perspective, both with numbers and feelings, and then do a review of our goals.

Thank you from Chris. Clear Ornaments hanging from the sky with gold glitten in them. A cookie candy cane embedded into the ground. Little tiny pine trees with gold stands and frosted white tips.

Overall Traffic Analytics Numbers

The site saw 88m pageviews this year down 6% from the 93m last year. Traffic has yo-yo’d up and down a smidge like that a little over the last 4-5 years, but this 6% is a bit of an alarming drop that I don’t like to see. These numbers are from Google Analytics, and some of my own reasearch this year suggests perhaps 20-30% of visitors to this site actually block the run-of-the-mill client-side JavaScript-powered Google Analytics I use. So perhaps the real traffic is higher, but as the analytics implementation is exactly the same and I don’t see any reason blocking would have skyrocketed just this past year alone, the downward movement seems real.

A ~3% drop in organic search traffic was largely responsible for the dip. That’s big, as search is 74.6% of all traffic. This points to us just not hitting the mark well enough for what people are searching for. A nice 36% increase in direct traffic points to pretty decent brand awareness, but direct traffic is only 5% of overall traffic anyway so it doesn’t make much of a difference compared to search engine traffic. Referral traffic is down, social is up, but both are such small slices right now they just don’t move the needle.

You might think, well hey content ages out, search engine traffic to existing content will decline over time. That’s true, but we publish a ton of new content every year as well as maintain and improve existing content, hence the concern.

We invest well into 6-figures in new and updated content every year. So seeing a decline in traffic is disheartening.

But hey that’s the game sometimes. I suspect it’s heavy competition in the developer writing space, which is something we all benefit from as developers, so it ain’t all bad. We’ll live and learn and do our best to turn it around for the sake of the health of this site. I’ve already got (counts fingers and toes) a million ideas.

All that said, while I do think pageviews is an interesting and relevant metric to a site that uses advertising as a primary business model, there are many others. Unique Visitors are up year over year to 26.3m from 25.8m, suggesting more different people came to the site this year, which is great, they just didn’t bop around the site as much or come back quite as often. Pages per visit is very steady at 1.35 meaning for the most part people come, they read, they leave. No surprise there. It’s mostly that “come back” thing to work on.

The Biggest Leap in Mobile Traffic Yet

Pretty big jump in mobile usage this year!

2021: 20%
2020: 15%
2019: 15%
2018: 12%

A fifth of all traffic is pretty interesting. Before 2018, even though mobile traffic was surging then too, we were in the low single digits, which I always thought hey this is a reference site for coding and people code on desktop. But clearly, that’s changing and perhaps people are reading the site in a more news kinda way, which I like. For years I had goals of making this site both full of referential long-green content and a site you could subscribe to for news, like an industry rag. So far so good.

Content by the Numbers

You’d think if we missed the mark on new content this year, that perhaps some better year would beat articles-written-in-2021 in traffic, but that’s not the case. Articles written in 2021 drove the most traffic to the site in 2021 (13.5% of overall traffic). Here are the articles that were top-by-pageviews in 2021 that were written in 2021:

  1. VS Code Extensions for HTML — Chris Coyier
  2. How to Create Neon Text With CSS — Silvia O’Dwyer
  3. Animating with Lottie — Idorenyin Udoh 
  4. Did You Know About the :has CSS Selector? — Robin Rendle
  5. A table with both a sticky header and a sticky first column — Chris Coyier
  6. Comparing the New Generation of Build Tools — Hugh Haworth 
  7. Mistakes I’ve Made as an Engineering Manager — Sarah Drasner 
  8. Let’s Create a Custom Audio Player — Idorenyin Udoh 
  9. HTML Inputs and Labels: A Love Story — Amber Wilson
  10. Front-End Testing is For Everyone — Evgeny Klimenchenko

I almost shouldn’t post these lists! Look at what happens to Daniel Aleksandersen.

Those articles above range from 100k pageviews to 71k pageviews. What’s interesting is that if you group together all posts that got 40k or more pageviews, there are 44 of them, putting them at about 2.5-3m pageviews. That’s kinda cool I think — the “medium tail” of content is pretty thick around here. The flexbox guide page alone did 6.7m pageviews, so that’s still a beast, but it is bested by all content published in 2021 which clocks in at 11.8m. So investing in content works, it just needs to get tuned such that we aren’t dropping overall. Perhaps that means SEO tuning of both new content and old.

Here’s 11-20 from 2021 just for fun:

  1. To the brain, reading computer code is not the same as reading language — Geoff Graham 
  2. In Praise of the Unambiguous Click Menu — Mark Root-Wiley 
  3. aspect-ratio — Geoff Graham 
  4. Theming and Theme Switching with React and styled-components — Tapas Adhikary 
  5. The Holy Grail Layout with CSS Grid — Chris Coyier
  6. Creating the Perfect Commit in Git — Tobias Günther 
  7. What if… you could use Visual Studio Code as the editor of in-browser Developer Tools? — Geoff Graham 
  8. Is CSS a Programming Language? — Chris Coyier
  9. A Love Letter to HTML & CSS — Ashley Kolodziej 
  10. JSON in CSS — Chris Coyier

And here’s the top 10 regardless of year, but still scoped to traffic-in-2021:

  1. A Complete Guide to Flexbox
  2. A Complete Guide to Grid
  3. Perfect Full Page Background Image
  4. Using SVG
  5. The Shapes of CSS
  6. Media Queries for Standard Devices
  7. box-shadow
  8. CSS Triangle
  9. How to use @font-face in CSS
  10. How to Scale SVG

I like seeing the Almanac not only perform pretty well overall but have some individual pages be top-performers on their own.

Comments

We had about 4,320 legit comments on the site this year, almost exactly the number from last year. Weird!

That seems like a lot, especially as we approve… I’d say half?… of commenters that are left. There is a lot of junk posts (e.g. “good post!” kinda stuff, that we just don’t post as to not bother the author with useless email notifications of new comments, nor readers with useless content). We just delete those junk posts (as in, not approve them in the first place).

There is spam too of course. We crossed the 2m spam comments threshold, but through a combination of Akismet and Anti-Spam not too much spam sneaks through and is easily trashed before approval.

Mentally, I really rollercoaster on comments. Sometimes they are great and helpful. Sometimes they are full of rudeness, hate, and anger. Those need to be looked at and trashed, meaning comments represent an entry point into my brain for all that negativity. Part of me thinks we should just shut them off, and if people have something important to say, we can encourage them to use their own blog (it ain’t hard to spin one up!) to comment and we’ll link to it if it’s good.

But then I think of all the helpful comments and comments that help keep me up to date. Heck I just learned that Chrome is postponing all that removal of alert() stuff via a comment from Kyle, and I probably would have missed that otherwise. Plus the fact that there are

4,320 of them this year that pass muster feels like the scale is tipped toward keeping them.

Newsletter

We’re at about 91,000 newsletter subscribers as this year wraps, up from 81,000 last year. A respectable march forward and makes it likely we’ll hit that 100k milestone sometime in 2022.

Huge props to Robin for leading up the newsletter with wonderful writing. I think he really found a voice and stride on the newsletter this year.

We didn’t miss a single week. Part of what helps there is that they have sponsors so there is some clear obligation to get them out on time, but I think it’s more like we have a system and the system works.

I’d really like to juice up newsletter subscriptions moreso because I think it’s actually a darn nice weekly read than for any specific business reason.

Video

Thanks to Dave’s idea that we get ShopTalk more into video, we’ve been using the CSS-Tricks YouTube channel and thus had a banner year in publishing video! 35 brand new videos!

Site Updates

The design evolved a bit this year, but nothing overly dramatic. Normally this time of year my fingers are itching for a new design, and believe me there are Figma drafts cooking, but I just haven’t had the time or inspiration for a true v19 just yet.

So no major changes to the tech behind the site, but plenty of minor ones. For instance:

  • The Yoast SEO plugin was giving me problems. It had super frequent updates, which I guess is good, but there was a high frequency of problems with the updates where either the core plugin or the pro plugin wouldn’t update correctly (up to causing such problems as literally taking down the site) and settings getting messed up during updates. For a while I just turned it off entirely. But then I started hearing good things about RankMath so I’m trying that, and so far so good. It’s got me kinda inspired to take content SEO more seriously. Yoast had some claws in the site in other ways, for example it provides a pretty nice Table of Contents block that I’m still searching for a solution for (maybe it’s coming to core?). It also had pretty nice breadcrumbs, and had to switch over to Breadcrumb NavXT.
  • Jetpack Boost is new to the site this year, and I’m impressed at how it handles critical CSS. Jetpack (full disclosure: a long time sponsor) is generally extremely helpful. I particularly like how the site search works, which is just out-of-the-box Jetpack Instant Search.
  • We really dialed in the social media images this year.
  • We also dialed in the eCommerce situation. The MVP Supporter membership unlocks additional content on the site, which I can now provide in eBook formats. So I’m really all set to produce more of that type of content.

Goal Review

🚫 Publish Three Guides. I thought this would be easy since last year our goal was 2 guides and we published 9! But this year we only managed one: A Complete Guide to Custom Properties. We did publish some other pretty big series like Tobias Günther’s 9-part Advanced Git series and four more entries in Jay Hoffman’s Web History series.

🚫 Stay focused on how-to technical content around our strengths. Kind of a close call here. It’s not like we didn’t publish quite a bit of how-to technical content. But I’m going to say we failed because I don’t think we kept this in mind strongly enough throughout the year. We didn’t say “we’re good at this type of content so we’re going to lean into that specifically” like this goal suggested we should. 🚫 Complete all missing Almanac entries. I hate marking this as failed, but I’m only doing that because of how it was worded with “all”. I think I had in mind that there was a really clear finite number of Alamanc articles to finish and we just had to do that. I think it’s a lot more wishy-washy than that, partially because of editorial choices (do you publish a unique entry for every single logical property or group them, for example).

But also, should we build an SVG-specific section? Should we have a new section for all the @at rules? It’s hard to say when the Almanac would be “complete”, so I’d just rather not. This page really needs a cleanup, but it’s got many ideas in there for more work that needs to be done/commissioned if anyone is so inclined.

We did do a pretty good job on publishing new entries though — more than any relatively recent year!

Almanac Entry Published
scale 2021-11-10
translate 2021-11-09
rotate 2021-11-08
mask-border 2021-11-03
padding-inline 2021-09-23
overscroll-behavior 2021-09-14
border-block 2021-09-02
outline-color 2021-09-01
accent-color 2021-08-26
block-size 2021-08-25
outline-style 2021-08-16
outline-width 2021-08-10
text-emphasis 2021-08-04
::backdrop 2021-08-03
hyphenate-limit-chars 2021-07-15
:fullscreen 2021-07-14
mask 2021-07-02
content-visibility 2021-06-21
place-content 2021-05-13
mask-composite 2021-05-10
:empty 2021-04-27
:where 2021-03-23
justify-self 2021-03-18
mask-type 2021-03-02
place-self 2021-03-02
:current 2021-02-23
:future 2021-02-23
border-boundary 2021-02-09
mask-mode 2021-02-03
caret-shape 2021-01-27
caret 2021-01-27
aspect-ratio 2021-01-20
margin-inline 2021-01-14
margin-inline-end 2021-01-14
margin-block-start 2021-01-08
margin-block-end 2021-01-08
margin-block 2021-01-06

Settting 2022 Goals

  • More SEO focus. I’ve almost shunned SEO in the past. Partially because the HTML best practices seem pretty easy and obvious, and my inbox is so full of total slimeball link builders I’d like to see do literally anything else with their time. Butttt. I’m just being ignorant about it. I think it will be fun, interesting, and likely useful to take a more considered look at SEO best practices for a content site like this and make a stab at improving it. The related goal being: Gain 10% in pageview traffic. We lost 6% this year, so I think 10% will get us back on track and moving upward. But it’s a big goal so I’m already nervous about it.
  • Another digital book. All the infrastructure is there for this and I’ve got ideas. I just need to write and put it in place.
  • More social media experimentation. That’s a loosey-goosey goal but whatever, we’ve got our work cut out for us in other ways. Like SEO, for a few years there I kinda shunned dedicated social media work for the CSS-Tricks brand. Mostly because when I look at the traffic numbers, so very little of it comes from social media, especially considering how much time we were spending on it in the past. We don’t really benefit much from brand social media, so why bother? Well, maybe I was thinking about it the wrong way. Maybe we can just not care what traffic it drives but care about the connection with readers directly there. If we’re more fun and interesting on social media, maybe we continue to build trust in what we’re doing here. Maybe it can help drive sales if we get that second goal done. Maybe its more directly monetizeable.

Thank You

Special thanks to Geoff! If you didn’t know, he’s our lead editor around here and keeping this entire site humming along nicely. You’ll work with Geoff if you do any guest writing here at all.

Special thanks to our biggest year-long sponsors Automattic and Frontend Masters. Our year-end series is both a thank you to you the readers and to them.

To another year!

🙏


Thank You (2021 Edition) originally published on CSS-Tricks



from CSS-Tricks https://ift.tt/3sUtRzb
via IFTTT

Passkeys: What the Heck and Why?

These things called  passkeys  sure are making the rounds these days. They were a main attraction at  W3C TPAC 2022 , gained support in  Saf...