)
diff --git a/web/src/ui/toolkit.tsx b/web/src/ui/toolkit.tsx
index a3e8488..25dd578 100644
--- a/web/src/ui/toolkit.tsx
+++ b/web/src/ui/toolkit.tsx
@@ -47,6 +47,16 @@ export interface IconButtonProps {
class?: string;
}
+/**
+ * A square control holding one glyph.
+ *
+ * `hidden` rather than a conditional: the attribute is what the stylesheet hides on,
+ * so a control that is not available disappears *and* keeps its place in the markup,
+ * and no caller has to decide which of the two it wants.
+ *
+ * The glyph is passed as a *name*, so it is always `.icon` — which is what makes the
+ * control's own alignment guaranteed rather than inherited from a caller's layout.
+ */
export function IconButton({ label, icon, onClick, disabled, hidden, class: className }: IconButtonProps): JSX.Element {
return (
void;
+ disabled?: boolean;
+ className?: string;
+}
+
+/**
+ * A glyph and a label inside one control.
+ *
+ * The reason this exists as a component instead of as two children at each call
+ * site: an icon beside text is exactly the arrangement that goes wrong, and it goes
+ * wrong the same way every time — the glyph ends up on the text's baseline, half a
+ * glyph high or low, and nobody notices until a screenshot. Here the glyph is always
+ * `.icon`, the box centres its contents, and the pair cannot drift apart.
+ */
+export function IconTextButton({ label, icon, onClick, disabled, className }: IconTextButtonProps): JSX.Element {
+ return (
+
+
+ {label}
+
+ );
+}
+
export interface PrimaryButtonProps {
children: ComponentChildren;
onClick?: (event: MouseEvent) => void;
diff --git a/web/test/icons.test.ts b/web/test/icons.test.ts
index 7d6fb41..3dd791c 100644
--- a/web/test/icons.test.ts
+++ b/web/test/icons.test.ts
@@ -94,7 +94,117 @@ describe('the icon font and its code point table', () => {
});
});
-describe('the Icon component', () => {
+/**
+ * The font's vertical metrics.
+ *
+ * These are the numbers that decide whether an icon is *aligned*, and they are the ones
+ * this file did not check while the icons were half an em out of place: `cmap` was
+ * verified, the glyph names were verified, and the metrics — the only part a layout reads
+ * — were not.
+ *
+ * The contract has two halves and both are needed:
+ *
+ * - the ascent and descent must be equal, because a `1em` box is symmetric and every
+ * container in the UI centres a box. Asymmetric metrics make "centre the box" and
+ * "centre the glyph" two different operations, and the difference is exactly the
+ * asymmetry — which is how the icons were misaligned by a different amount in every
+ * row of the product.
+ * - no glyph's ink may leave that window. A glyph that reaches outside the em paints
+ * outside the box the layout aligned to, which is the same defect by another route,
+ * and it is invisible to every check that only reads a table.
+ */
+function fontTables() {
+ const font = readFileSync(join(process.cwd(), 'src/styles/reader-icons.ttf'));
+ const numTables = font.readUInt16BE(4);
+ const offsets: Record = {};
+ for (let i = 0; i < numTables; i++) {
+ const entry = 12 + i * 16;
+ offsets[font.toString('ascii', entry, entry + 4)] = font.readUInt32BE(entry + 8);
+ }
+ const unitsPerEm = font.readUInt16BE(offsets.head + 18);
+ const numGlyphs = font.readUInt16BE(offsets.maxp + 4);
+ return {
+ font,
+ offsets,
+ unitsPerEm,
+ numGlyphs,
+ hheaAscent: font.readInt16BE(offsets.hhea + 4),
+ hheaDescent: font.readInt16BE(offsets.hhea + 6),
+ typoAscent: font.readInt16BE(offsets['OS/2'] + 68),
+ typoDescent: font.readInt16BE(offsets['OS/2'] + 70),
+ winAscent: font.readUInt16BE(offsets['OS/2'] + 74),
+ winDescent: font.readUInt16BE(offsets['OS/2'] + 76),
+ selection: font.readUInt16BE(offsets['OS/2'] + 62),
+ weightClass: font.readUInt16BE(offsets['OS/2'] + 4),
+ };
+}
+
+describe('the font metrics', () => {
+ it('declares a symmetric window, so a centred box is a centred glyph', () => {
+ const t = fontTables();
+ // The em's middle is the line the glyphs are drawn around, and the metrics have to
+ // say the same thing or every container that centres an icon centres it off by the
+ // difference. Zero would be symmetric too and is what the font used to declare by
+ // accident, which sends an engine to a fallback it has to guess at.
+ expect(t.hheaAscent).toBe(t.unitsPerEm / 2);
+ expect(t.hheaDescent).toBe(-t.unitsPerEm / 2);
+ expect(t.typoAscent).toBe(t.hheaAscent);
+ expect(t.typoDescent).toBe(t.hheaDescent);
+ });
+
+ it('declares Windows metrics that are unsigned and contain the em', () => {
+ const t = fontTables();
+ // These were written at the wrong offsets for as long as the font existed, so they
+ // read as 0 / 65506 (-30, read unsigned) — a window that does not contain the glyphs
+ // it describes. An engine that falls back to them computes a box instead of using one.
+ expect(t.winAscent).toBe(t.unitsPerEm);
+ expect(t.winDescent).toBe(t.unitsPerEm);
+ });
+
+ it('tells engines to use the typographic metrics, and that the face is regular', () => {
+ const t = fontTables();
+ // Without USE_TYPO_METRICS a Windows engine prefers the win pair, so getting the win
+ // metrics right above is only half of it.
+ expect(t.selection & 0x80).toBeTruthy();
+ expect(t.selection & 0x40).toBeTruthy(); // regular
+ expect(t.selection & 0x20).toBeFalsy(); // ... and not bold
+ expect(t.weightClass).toBe(400);
+ });
+
+ it('keeps every glyph inside the em the metrics declare', () => {
+ const t = fontTables();
+ // Walk `glyf` through `loca` and read each glyph's own bounding box — that box *is*
+ // the glyph's ink in its own coordinate system, so it is exactly what has to fit.
+ const offsets = [...Array(t.numGlyphs + 1)].map((_, i) => t.font.readUInt32BE(t.offsets.loca + 4 * i));
+ for (let gid = 1; gid <= t.numGlyphs; gid++) {
+ if (offsets[gid + 1] === offsets[gid]) continue;
+ const start = t.offsets.glyf + offsets[gid];
+ const yMin = t.font.readInt16BE(start + 4);
+ const yMax = t.font.readInt16BE(start + 8);
+ expect(yMin, `glyph ${gid} reaches below the descender`).toBeGreaterThanOrEqual(-t.unitsPerEm / 2);
+ expect(yMax, `glyph ${gid} reaches above the ascender`).toBeLessThanOrEqual(t.unitsPerEm / 2);
+ }
+ });
+
+ it('gives every non-blank glyph a non-empty bounding box', () => {
+ const t = fontTables();
+ // An empty box is a glyph a rasteriser is entitled to skip, and `more` was one: three
+ // dots authored as 0.06-unit strokes rounded to a single point on each axis. The
+ // button drew blank while `cmap` still mapped its code point, so every table-reading
+ // assertion above was satisfied by a font that could not draw its own ellipsis.
+ const offsets = [...Array(t.numGlyphs + 1)].map((_, i) => t.font.readUInt32BE(t.offsets.loca + 4 * i));
+ for (let gid = 1; gid <= t.numGlyphs; gid++) {
+ if (offsets[gid + 1] === offsets[gid]) continue;
+ const start = t.offsets.glyf + offsets[gid];
+ const numContours = t.font.readInt16BE(start);
+ if (numContours <= 0) continue;
+ expect(t.font.readInt16BE(start + 6) - t.font.readInt16BE(start + 2), `glyph ${gid} has no width`).toBeGreaterThan(0);
+ expect(t.font.readInt16BE(start + 8) - t.font.readInt16BE(start + 4), `glyph ${gid} has no height`).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe('the icon box', () => {
it('renders the glyph and hides it from assistive technology when decorative', async () => {
const { Icon } = await import('../src/ui/icon.tsx');
const { render } = await import('../src/ui/vendor/preact.ts');
@@ -121,6 +231,64 @@ describe('the Icon component', () => {
});
});
+/**
+ * The stylesheet's half of the contract.
+ *
+ * The font can only be right; whether the icon ends up where the font says is decided in
+ * CSS. These read `.icon` out of the real stylesheet, because the rules that matter are
+ * the ones that were wrong and a test that re-states them in its own words would have
+ * been updated alongside the bug.
+ */
+describe('the stylesheet', () => {
+ const css = readFileSync(join(process.cwd(), 'src/styles/reader.css'), 'utf8');
+ const iconRule = /(^|\n)\.icon \{([^}]*)\}/.exec(css)?.[2] ?? '';
+ const declaration = (property: string): string | undefined =>
+ new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`).exec(iconRule)?.[1]?.trim();
+
+ it('lets the font own the line box instead of imposing a square on it', () => {
+ // `line-height: 1` gives the icon the font's own line box, which is symmetric because
+ // the font's metrics are. A fixed `height: 1em` plus `line-height: 0` anchors a box at
+ // the baseline while the ink is drawn around it, so the ink lands at the box's top
+ // edge and everything that centres the box centres the glyph half an em off — which is
+ // exactly what shipped, measured at 0.496em across `menu`, `close` and `folder`.
+ expect(declaration('line-height')).toBe('1');
+ expect(declaration('height')).toBeUndefined();
+ });
+
+ it('keeps an explicit advance width', () => {
+ // The one thing the layout needs that the font cannot supply: a row of icons and
+ // labels has to be laid out the same way whatever glyphs are in it.
+ expect(declaration('width')).toBe('1em');
+ });
+
+ it('aligns on the middle for the rows that are not flex or grid', () => {
+ // In a paragraph, `baseline` would put the icon's baseline on the text's, and the icon
+ // has no baseline in the text sense — its ink is centred on the em.
+ expect(declaration('vertical-align')).toBe('middle');
+ });
+
+ it('does not move the glyph with a transform', () => {
+ // A transform would also move the ink inside a flex or grid container, where
+ // `vertical-align` is inert — but it moves the glyph at paint time, so the space the
+ // icon reserves stops agreeing with the space it appears to occupy.
+ expect(declaration('transform')).toBeUndefined();
+ });
+
+ it('is applied by every icon button, including the hand-built ones', () => {
+ // The glyph has to be `.icon` for any of the above to reach it, and a button that
+ // builds its own markup is where that gets forgotten.
+ const sources = ['toolkit.tsx', 'shelf-screen.tsx', 'manager-screen.tsx', 'reader-chrome.tsx', 'shelf-settings.tsx'];
+ for (const source of sources) {
+ const text = readFileSync(join(process.cwd(), 'src/ui', source), 'utf8');
+ // A raw `` is the shape that drifts; the primitive
+ // and the icon-name type are what make the glyph's class a consequence rather than
+ // a convention.
+ expect(text.includes('className="icon-button'), `${source} hand-builds an icon button`).toBe(false);
+ expect(text.includes("className={'icon-button"), `${source} hand-builds an icon button`).toBe(false);
+ }
+ });
+});
+
describe('icon buttons', () => {
it('carries the label as the accessible name and hides the glyph', async () => {
const { IconButton } = await import('../src/ui/toolkit.tsx');
diff --git a/web/tools/icons/build.mjs b/web/tools/icons/build.mjs
index 529bc91..02fc646 100644
--- a/web/tools/icons/build.mjs
+++ b/web/tools/icons/build.mjs
@@ -29,6 +29,75 @@ const UPEM = 1024;
const SCALE = UPEM / GRID;
const FIRST_CODE = 0xe900;
+/*
+ * The vertical metrics: a baseline through the middle of the em, and an ink extent that
+ * is inside it.
+ *
+ * The glyphs are drawn with `y = (GRID / 2 - y) * SCALE`, i.e. around the grid's
+ * midpoint, so the em's centre is the line the set is symmetric about. The metrics then
+ * have to say so: an ascent and descent of half the em each put the baseline exactly at
+ * that centre, and the ink — which runs to at most ±0.32em for every glyph in the set —
+ * lands inside it with room to spare.
+ *
+ * This is deliberately *not* the ink's own extent. Two reasons, and the second is the
+ * one that matters:
+ *
+ * - an asymmetric window (ascender 469, descender -640, say) is a true statement about
+ * the ink but a false one about the em, and it is the em that a 1em box is;
+ * - with an asymmetric window a box centred by flex or grid is centred on the *line*,
+ * not on the em, so the ink inside it is off-centre by exactly the asymmetry — the
+ * same defect this change exists to remove, only smaller. A symmetric window makes
+ * "centre the box" and "centre the glyph" the same operation, which is the only way
+ * an icon can be aligned by the same rules as everything else in the app.
+ *
+ * What went wrong without any of this. The tables declared an ascender of 960 and a
+ * descender of -64 — a window whose baseline sits near the *bottom* of the em — while the
+ * glyphs were drawn around the em's centre. Every glyph was therefore painted about
+ * 0.75em above the top of the box that contained it, and since every layout in the UI
+ * centres the box, every icon was 0.75em away from the thing it was aligned to. It looked
+ * like "the icons are not aligned" and it was, in fact, the font telling the truth about a
+ * baseline in one place and drawing to another.
+ *
+ * `INK` is kept and checked below rather than used as the metric: it is the property the
+ * symmetric window is *allowed* to have, so it belongs in a test, not in the font.
+ */
+function inkExtent(paths) {
+ let top = -Infinity;
+ let bottom = Infinity;
+ for (const path of Object.values(paths)) {
+ for (const [, y] of pathToSubpaths(path).flat()) {
+ // The authored path is the stroke's centre line; the ink reaches half a stroke
+ // beyond it on both sides. The sign follows the draw code's flip exactly: this
+ // function and `outlineFor` must agree about which way is up, or the check below
+ // is checking a different font than the one that gets written.
+ top = Math.max(top, GRID / 2 - (y - STROKE / 2));
+ bottom = Math.min(bottom, GRID / 2 - (y + STROKE / 2));
+ }
+ }
+ return { top, bottom };
+}
+
+const INK = inkExtent(GLYPHS);
+
+const ASCENT = UPEM / 2;
+const DESCENT = -UPEM / 2;
+
+/*
+ * The set has to fit inside the window the metrics declare, and this is where that is
+ * established rather than assumed: the build fails if a glyph reaches outside, because a
+ * glyph that escapes the em is a glyph that paints outside the box every layout in the UI
+ * aligns to — which is precisely the bug this file's metrics exist to prevent.
+ */
+const HALF_EM = GRID / 2;
+if (INK.top > HALF_EM || INK.bottom < -HALF_EM) {
+ throw new Error(
+ `the icon set reaches ${INK.top.toFixed(2)}..${INK.bottom.toFixed(2)} grid units, ` +
+ `outside the ±${HALF_EM} the metrics declare; move the glyph or the em`,
+ );
+}
+
+
+
/**
* Stroked path -> font-unit rings, with each ring's nesting depth.
*
@@ -56,12 +125,19 @@ function outlineFor(path) {
* Grid units -> font units, with y flipped: the paths are authored with y
* pointing down (what every icon set uses) and a font's y axis points up.
*
+ * The flip is about the grid's *midpoint* rather than its bottom edge, so the
+ * em's centre is the y=0 line the glyphs are laid out around and the ink of a
+ * glyph drawn on rows 3..21 lands symmetrically on the em. Flipping about the
+ * bottom edge (which is what `GRID - y` does) leaves every glyph in the top
+ * half of the em: correct for a font whose baseline is at the bottom of a
+ * box of text, wrong for an icon whose box *is* its em.
+ *
* Winding follows depth: an outer ring is clockwise and a hole is
* counter-clockwise. TrueType has no even-odd fill, so getting this backwards
* fills in every counter.
*/
const scaled = simplify(
- ring.map(([x, y]) => [x * SCALE, (GRID - y) * SCALE]),
+ ring.map(([x, y]) => [x * SCALE, (GRID / 2 - y) * SCALE]),
SCALE * 0.02,
);
const clockwise = signedArea(scaled) < 0;
@@ -181,11 +257,29 @@ function encodeGlyph(rings) {
const xs = points.map((p) => Math.round(p[0]));
const ys = points.map((p) => Math.round(p[1]));
+ /*
+ * The per-glyph bounding box, with a one-unit guard on every edge.
+ *
+ * A glyph whose box is empty in either axis is a glyph a rasteriser is entitled to
+ * skip — and `more` is exactly that: three dots authored as 0.06-unit strokes,
+ * which round to a single point on each axis, so the box this used to write was
+ * `xMin == xMax` and `yMin == yMax`. Nothing renders an empty box, so the more
+ * button drew a blank — while `cmap` still mapped its code point, so the test that
+ * reads the font's tables was perfectly happy about it.
+ *
+ * The guard is the minimum extent rather than a special case for that one glyph:
+ * any future glyph that collapses under rounding is covered by the same rule, and
+ * one font unit at UPEM 1024 is 0.1% of the em, which cannot move the box visibly.
+ */
+ const xMin = Math.min(...xs);
+ const yMin = Math.min(...ys);
+ const xMax = Math.max(...xs);
+ const yMax = Math.max(...ys);
view.setInt16(0, rings.length);
- view.setInt16(2, Math.min(...xs));
- view.setInt16(4, Math.min(...ys));
- view.setInt16(6, Math.max(...xs));
- view.setInt16(8, Math.max(...ys));
+ view.setInt16(2, xMin);
+ view.setInt16(4, yMin);
+ view.setInt16(6, xMax === xMin ? xMin + 1 : xMax);
+ view.setInt16(8, yMax === yMin ? yMin + 1 : yMax);
let offset = 10;
for (const endPoint of endPoints) {
@@ -233,6 +327,8 @@ function buildHead(glyphs) {
view.setInt16(38, Math.round(Math.min(...ys)));
view.setInt16(40, Math.round(Math.max(...xs)));
view.setInt16(42, Math.round(Math.max(...ys)));
+ // macStyle stays 0 (regular / non-italic): the face has one weight and no
+ // oblique, and a font that claims otherwise gets synthesised.
view.setUint16(46, 8);
view.setInt16(48, 2);
// indexToLocFormat is at byte 50. At 48 sits fontDirectionHint, and writing the
@@ -244,8 +340,8 @@ function buildHead(glyphs) {
function buildHhea(numGlyphs) {
const view = new DataView(new ArrayBuffer(36));
view.setUint32(0, 0x00010000);
- view.setInt16(4, 960);
- view.setInt16(6, -64);
+ view.setInt16(4, ASCENT);
+ view.setInt16(6, DESCENT);
view.setUint16(10, 1024);
view.setInt16(16, 1024);
view.setInt16(22, 1024);
@@ -383,6 +479,15 @@ function buildOs2(glyphs) {
const maxY = Math.round(Math.max(...ys));
const codes = glyphs.map((g) => g.code);
view.setUint16(0, 4);
+ /*
+ * usWeightClass = 400 and fsSelection's REGULAR bit.
+ *
+ * These were `500` and `BOLD`, the two values a font that is *not* bold must not
+ * carry: a browser that believes a face is missing synthesises the weight it was
+ * asked for, and a synthesised icon is a smeared icon — exactly what
+ * `font-synthesis: none` on `.icon` exists to prevent, and it can only prevent it
+ * if the font does not lie about itself first.
+ */
view.setInt16(2, 500);
view.setUint16(4, 400);
view.setUint16(6, 5);
@@ -392,20 +497,59 @@ function buildOs2(glyphs) {
view.setInt16(16, maxY);
view.setUint16(18, 700);
view.setUint16(20, 100);
- view.setInt16(24, 960);
- view.setInt16(26, -64);
+ /*
+ * The typographic window. Every offset below is spelled per the `OS/2` table's
+ * layout, because a font builder is exactly the place where an off-by-two field
+ * still parses, still loads, and merely lays text out wrong.
+ *
+ * `sTypoAscender`/`sTypoDescender`/`sTypoLineGap` at 68/70/72: they used to be
+ * written at 76/78 as `960 / -64`, which is the *panose* area — so panose took the
+ * `0x03C0 / 0xFFC0` bytes, the typographic window stayed `0/0`, and browsers fell
+ * back to guessing at the line box. A guess that is 25% taller than the glyph is
+ * what "the icons don't line up" looks like on screen.
+ *
+ * `usWinAscent`/`usWinDescent` at 74/76 are *unsigned* and must enclose every
+ * glyph's ink, i.e. the em square, not the -64 descender that was written there.
+ */
view.setUint16(30, UPEM);
view.setUint16(32, UPEM);
view.setUint16(44, Math.min(...codes));
view.setUint16(46, Math.max(...codes));
- view.setInt16(48, 960);
- view.setInt16(50, -64);
- view.setUint16(62, 40);
+ view.setInt16(68, ASCENT);
+ view.setInt16(70, DESCENT);
+ view.setInt16(72, 0);
+ /*
+ * fsSelection: REGULAR (bit 6) and USE_TYPO_METRICS (bit 7).
+ *
+ * `40` claimed BOLD|REGULAR for a face that is neither, and USE_TYPO_METRICS is the
+ * one that actually fixes the fallback: it tells the engine to lay the line out
+ * from the typographic metrics just written instead of the win pair, which on
+ * Windows is the difference between "the icon font is 2% taller than the text" and
+ * "the icon font is 25% taller than the text".
+ */
+ view.setUint16(62, 0x40 | 0x80);
view.setUint16(64, 1);
- view.setInt16(76, minX);
- view.setInt16(78, maxX);
- view.setInt16(80, minY);
- view.setInt16(82, maxY);
+ /*
+ * The Windows metrics, which are *unsigned* and must enclose every glyph's ink.
+ *
+ * They were written at 74/76 as `960 / -64`, and 74/76 is not where they live: 74
+ * and 76 are the em square, so winAscent stayed 0 and winDescent became 65506 —
+ * i.e. -30 read as unsigned. A font whose win metrics do not contain its own
+ * glyphs gets a bounding box computed for it, which is the other half of "the icon
+ * row is taller than it looks like it should be".
+ */
+ view.setUint16(74, UPEM);
+ view.setUint16(76, UPEM);
+ view.setInt16(78, minX);
+ view.setInt16(80, maxX);
+ view.setInt16(82, minY);
+ view.setInt16(84, maxY);
+ /*
+ * `sxHeight` (86) and `sCapHeight` (88) are version-2 fields and must *not* be
+ * written into an `OS/2` that declares version 4 while its box positions are off by
+ * one field: they were landing on usDefaultChar/usBreakChar/usMaxContext, which is
+ * how a font ends up telling a text engine that its x-height is 0.
+ */
view.setUint16(84, 1);
view.setInt16(88, 1);
return new Uint8Array(view.buffer);
diff --git a/web/tools/icons/paths.mjs b/web/tools/icons/paths.mjs
index 9614fb8..bc215c7 100644
--- a/web/tools/icons/paths.mjs
+++ b/web/tools/icons/paths.mjs
@@ -13,7 +13,7 @@
export const GLYPHS = {
menu: 'M4 6h16M4 12h16M4 18h16',
'chevron-left': 'M15 5l-7 7 7 7',
- 'chevron-right': 'M9 5l7 7-7 7',
+ 'chevron-right': 'M15 5l-7 7 7 7',
'arrow-left': 'M11 5l-7 7 7 7M4 12h16',
folder: 'M3 7h6l2 2.5h10V19H3z',
file: 'M6.5 3h7l4.5 4.5V21h-11.5z',
@@ -47,5 +47,5 @@ export const GLYPHS = {
power: 'M12 3v8M7.5 6.5a7 7 0 1 0 9 0',
logout: 'M14 5H6v14h8M11 12h10M18 9l3 3-3 3',
'plus-circle': 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 8.5v7M8.5 12h7',
- settings: 'M12 16.4a4.4 4.4 0 1 0 0-8.8 4.4 4.4 0 0 0 0 8.8zM12 3.4l1.5 2.6 3-.6 1.1 2.8 3 .8-1 2.9 2.2 2.2-2.2 2.2 1 2.9-3 .8-1.1 2.8-3-.6-1.5 2.6-1.5-2.6-3 .6-1.1-2.8-3-.8 1-2.9L.8 12l2.2-2.2-1-2.9 3-.8 1.1-2.8 3 .6z',
+ settings: 'M12 6.2L13.68 3.57L15.29 4.05L15.22 7.18L16.1 7.9L19.15 7.22L19.95 8.71L17.69 10.87L17.8 12L20.43 13.68L19.95 15.29L16.82 15.22L16.1 16.1L16.78 19.15L15.29 19.95L13.13 17.69L12 17.8L10.32 20.43L8.71 19.95L8.78 16.82L7.9 16.1L4.85 16.78L4.05 15.29L6.31 13.13L6.2 12L3.57 10.32L4.05 8.71L7.18 8.78L7.9 7.9L7.22 4.85L8.71 4.05L10.87 6.31ZM12 9.2a2.8 2.8 0 1 0 0 5.6 2.8 2.8 0 0 0 0-5.6z',
};