Add a new avatar face

Worked example: adding a happy face to the avatar library. Unlike most extension points in these docs there is no type, registry or backend change involved: the library discovers its faces from a folder, so the whole job is one correctly shaped SVG file plus a visual check.

Step 1 — Create the SVG

File: resources/js/assets/faces/happy.svg

<svg xmlns="http://www.w3.org/2000/svg" id="happy" viewBox="0 0 128 128"><circle cx="64" cy="64" r="58" fill="#f8dc25"></circle><circle cx="44" cy="52" r="7" fill="#4a3600"></circle><circle cx="84" cy="52" r="7" fill="#4a3600"></circle><path d="M36 76c6 20 50 20 56 0" fill="none" stroke="#4a3600" stroke-width="6" stroke-linecap="round"></path></svg>

The file has to follow these rules, all of which come from how the library loads it:

  • Square with a viewBox (the existing faces use 0 0 128 128). The face is drawn onto a 256 by 256 canvas, so a non-square viewBox would be stretched.
  • Self-contained. No <script>, no external fonts, no href/src pointing at other files. The SVG is loaded through an <img> and a blob URL, which blocks external resources, so anything that depends on them renders blank or differently from how it looks in the grid.
  • A circle that fills the viewBox, with nothing painted in the corners. The grid shows each face inside a round button and the avatar is displayed in a circle everywhere else, so artwork in the corners is simply cropped.
  • The filename is the face's name. The id is the file name without .svg (happy), and it becomes the button's tooltip and its accessible name (Use happy avatar). Prefer a short, lowercase, descriptive name. Faces are listed alphabetically by file path.

Step 2 — Check that it is recognised after a reload

File: resources/js/Components/Organisms/AccountSettingsContent/AccountSettingsAvatarLibrary.tsx

You do not edit this file, but it is the code that picks your SVG up:

const faceModules = import.meta.glob<string>('../../../assets/faces/*.svg', {
    eager: true,
    query: '?url',
    import: 'default',
});

const faces = Object.entries(faceModules)
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([path, src]) => ({
        id: path.split('/').pop()!.replace('.svg', ''),
        src,
    }));

Vite resolves the glob at build time, so a new file needs the dev server to notice it (it normally does; if the new face does not show up, restart npm run dev or make up).

When a user picks a face, handleSelectFace in AccountSettingsProfileTab.tsx rasterizes it to a PNG and uploads it through account.upload-avatar. After a page reload the library marks the current avatar's face by comparing pixels, with a mean per-channel tolerance of 2 (out of 255) defined by MATCH_TOLERANCE in resources/js/utils/faces.ts. Two faces that differ by less than that would both match the same avatar, and the first one alphabetically would win. The closest pair of faces today differs by about 5.5, so keep a new face visibly different from every existing one (a different expression or colour is enough; recolouring a single small detail is not).

Step 3 — Look at it in the app

Run the app (composer dev or make up), open Account settings → Profile and click the avatar. Check:

  1. The new face shows in the grid, is round, and has a tooltip with its file name.
  2. Picking it updates the preview, saves without an error alert, and closes the library.
  3. Reload the page and open the library again: the same face should carry the check badge. If no face is marked, the uploaded PNG does not match the rasterized SVG, usually because the SVG relies on an external resource or is not square.

Tests

No test changes are needed for a new face: the existing tests do not depend on how many faces exist.

  • resources/js/Components/Organisms/AccountSettingsContent/AccountSettingsAvatarLibrary.test.tsx covers matching the current avatar and recovering when a face fails to load.
  • resources/js/Components/Organisms/AccountSettingsContent/AccountSettingsProfileTab.test.tsx covers rolling back the avatar when saving a face fails.
  • resources/js/utils/faces.test.ts covers rasterizeImage, canvasToBlob and canvasesMatch.

jsdom has no canvas, so these tests mock rasterization and pixel data. Pixel-level matching of real SVGs is only covered by the manual check in step 3.