diff --git a/plans/font-tooling.md b/plans/font-tooling.md deleted file mode 100644 index 9651f04ad357..000000000000 --- a/plans/font-tooling.md +++ /dev/null @@ -1,178 +0,0 @@ -# Font Tooling Plan - -## Goal - -Create a clean, repeatable font pipeline for Keybase text fonts and icon fonts, with explicit metrics and platform validation for iOS, Android, and Electron. - -The immediate visual issue is mobile strikethrough placement, but the plan should validate the whole typography setup so future font changes do not rely on hidden platform workarounds. - -## Current Findings - -- `shared/desktop/yarn-helper/font.mts` only generates `shared/fonts/kb.ttf`, the icon font, from `shared/images/iconfont/*.svg`. -- `shared/fonts/keybase*.ttf` are imported binary text fonts. The repo does not contain source `.glyphs`, `.ufo`, `.designspace`, or `.fea` files for them. -- The text fonts identify as Mark Simonson Studio fonts renamed to `Keybase`, then processed through Font Squirrel and `ttfautohint`. -- `shared/fonts/android/keybase*.ttf` are separate Android copies. Their only clear metric difference is a patched underline position from `shared/fonts/android/fixes.py`. -- The Android copies are a workaround, not a source-of-truth build. -- Current text fonts do not set `USE_TYPO_METRICS`; `OS/2` typo metrics and `hhea`/Windows metrics differ significantly. - -## Principles - -- Keep canonical source inputs separate from generated outputs. -- Prefer one generated font byte sequence per weight/style across all platforms; platform-specific filenames or registration are acceptable, platform-specific font metrics are not. -- Make every intentional metric explicit in a checked-in manifest. -- Make font changes reproducible from CLI commands. -- Validate both static font tables and rendered platform output. -- Keep icon font generation separate from text font generation. -- Remove legacy FontForge and `webfonts-generator` dependency from the core path. - -## Proposed File Layout - -- `shared/fonts/source/` - Canonical licensed input font files. This directory should include provenance notes and should not contain generated outputs. -- `shared/fonts/manifest.json` - Declarative source-to-output mapping for every Keybase text font and Source Code Pro font. -- `shared/fonts/metrics.json` - Expected OpenType table metrics for every generated font. -- `shared/fonts/generated/` - Temporary or ignored staging directory used by the build command before copying outputs into platform asset locations. -- `shared/tools/fonts/` - Python CLI tooling based on `fontTools`. -- `shared/tools/fonts/fixtures/` - Static strings and visual test definitions shared by RN and Electron test pages. -- `shared/fonts/README.md` - Human-facing documentation for source provenance, build commands, metric policy, and platform validation. - -## CLI Tooling - -- [ ] Add a Python CLI under `shared/tools/fonts/font_tool.py`. -- [ ] Pin Python dependencies for the font tooling, primarily `fontTools`. -- [ ] Add a command to inspect existing fonts and emit table data: - `inspect --inputs shared/fonts/*.ttf --output /tmp/keybase-font-inspect.json` -- [ ] Add a command to generate `shared/fonts/metrics.json` from the current fonts as a starting snapshot: - `snapshot-metrics --manifest shared/fonts/manifest.json --output shared/fonts/metrics.json` -- [ ] Add a command to build text fonts from canonical inputs: - `build-text --manifest shared/fonts/manifest.json` -- [ ] Add a command to verify generated text fonts against the manifest and metrics: - `verify-text --manifest shared/fonts/manifest.json --metrics shared/fonts/metrics.json` -- [ ] Add a command to render deterministic local HTML/SVG/canvas fixtures for quick desktop inspection. -- [ ] Add a command to summarize changed font tables between two directories: - `diff-metrics --before shared/fonts --after shared/fonts/generated` -- [ ] Add package scripts in `shared/package.json` only as wrappers around the CLI once the CLI exists: - `font:inspect`, `font:build-text`, `font:verify-text`, `font:diff-metrics`. - -## Text Font Build Requirements - -- [ ] Define canonical font sources and document where they came from. -- [ ] Decide whether `Keybase` stays a renamed family or whether the original licensed family names should remain in metadata. -- [ ] For every generated font, set and verify: - `name`, `head`, `hhea`, `OS/2`, `post`, `gasp`, `cmap`, `GPOS`, `GSUB`, and glyph bounds. -- [ ] Make family grouping explicit: - iOS/Electron can use `fontFamily: Keybase` with numeric weights; Android may need either family XML registration or separate family names. -- [ ] Decide whether to use Android `res/font` XML family registration so numeric weights can map cleanly. -- [ ] Remove the Android-only metric patch once the new generated outputs are validated. -- [ ] Keep Source Code Pro in the same verification pipeline, even if it is not modified. - -## Metrics Policy - -- [ ] Define target line metrics: - `OS/2.sTypoAscender`, `sTypoDescender`, `sTypoLineGap`, `hhea.ascender`, `hhea.descender`, `hhea.lineGap`, `usWinAscent`, `usWinDescent`. -- [ ] Decide whether to set `USE_TYPO_METRICS`. -- [ ] Define target decoration metrics: - `OS/2.yStrikeoutPosition`, `OS/2.yStrikeoutSize`, `post.underlinePosition`, `post.underlineThickness`. -- [ ] Define clipping policy for accents, combining marks, emoji-adjacent text, and descenders. -- [ ] Define per-weight tolerances where exact equality is not appropriate. -- [ ] Include derived ratios in generated reports, such as strike position relative to x-height and cap height. - -## Icon Font Tooling - -- [ ] Split icon font generation out of text font tooling. -- [ ] Replace `webfonts-generator` with maintained lower-level packages or a Python/fontTools-based builder. -- [ ] Preserve existing codepoint assignment from filename counters. -- [ ] Preserve deterministic glyph names and generated `icon.constants-gen.shared.tsx`. -- [ ] Preserve Windows installer behavior that required a unique TTF version, but document it and make it explicit. -- [ ] Verify `kb.ttf` metrics, GASP settings, and glyph bounds without FontForge. -- [ ] Decide whether web icon fonts are still needed; if not, remove the obsolete `update-web-font` path in a later cleanup. - -## Static Validation - -- [ ] Verify all generated outputs match `shared/fonts/metrics.json`. -- [ ] Verify generated fonts are byte-identical on repeated builds, except explicitly versioned fields. -- [ ] Verify no required glyph coverage is lost. -- [ ] Verify all name records are expected and contain no stale Font Squirrel or temporary build metadata unless intentionally retained. -- [ ] Verify Android asset filenames and/or XML family names match the React Native loader path. -- [ ] Verify iOS project resources and Info.plist include every required font. -- [ ] Verify Electron CSS `@font-face` names, weights, and paths match generated outputs. - -## App Test Surfaces - -- [ ] Add a dev-only Settings row named `Typography` or `Font debug`. -- [ ] Gate the row behind an existing dev/debug flag so it never appears in production release UI. -- [ ] Add a shared route/screen for native and desktop where practical. -- [ ] Keep the screen unframed and dense: it is a debugging tool, not a marketing page. -- [ ] Include controls for platform font scale, theme, text type, weight, decoration, sample string, and background. -- [ ] On React Native, capture `onTextLayout` for each sample and display ascender, descender, capHeight, xHeight, height, width, and line count. -- [ ] On Electron, display computed CSS font family, font weight, font size, line height, and `document.fonts.check(...)` result. - -## Visual Test Content - -- [ ] App text types: - `BodyTiny`, `BodySmall`, `Body`, `BodyBig`, `Header`, `HeaderBig`, terminal text. -- [ ] Weights and styles: - medium, semibold, bold, extrabold, italic, semibold italic, bold italic. -- [ ] Decorations: - none, underline, strikethrough, underline plus strikethrough. -- [ ] Markdown rendering: - `~~strike~~`, `**bold**`, `_italic_`, nested strong/em/strike, links, mentions, code, blockquote. -- [ ] Baseline strings: - `Hxpxgy`, `Hamburgefontsiv`, `0123456789`, punctuation, quotes, primes, oldstyle figures if supported. -- [ ] Clipping strings: - `ÁÉÍÓÚ ÅÄÖ Ñ Ç`, `gjpqy`, combining marks such as `é ñ ā`, and long descender-heavy lines. -- [ ] Real UI contexts: - chat message, username, team name, link, button label, input, nav row, popup/menu row, terminal/code block. -- [ ] Multi-line wrapping and tight line-height samples. - -## Platform Validation - -- [ ] Electron: Playwright screenshot of the font debug route at normal and dark themes. -- [ ] iOS: simulator screenshot of the font debug route at default and large text settings. -- [ ] Android: emulator screenshot of the font debug route at default and large text settings. -- [ ] Android: verify custom fonts are loaded for every intended weight and not synthesized or falling back. -- [ ] All platforms: compare screenshot fixtures against checked-in or saved baseline images. -- [ ] All platforms: run a simple pixel analysis for decoration bands on high-contrast text. - -## Decoration-Specific Tests - -- [ ] Render `xxxx`, `HHHH`, `agpxy`, and `Hamburgefontsiv` at every app font size. -- [ ] Locate the strikethrough band in screenshots and compare it to x-height/cap-height derived targets. -- [ ] Locate underline band and verify it clears descenders by the chosen minimum. -- [ ] Verify decoration thickness scales consistently by font weight. -- [ ] Verify markdown strikethrough and direct `Text` strikethrough produce the same result. -- [ ] Verify Electron CSS `text-decoration` and RN `textDecorationLine` are both covered. - -## Rollout Plan - -- [ ] Phase 1: Land documentation and static inspection tooling only. -- [ ] Phase 2: Generate metrics snapshots from current fonts and document the current platform differences. -- [ ] Phase 3: Add dev-only app font debug pages and screenshots without changing font bytes. -- [ ] Phase 4: Choose target metrics and update the build CLI to produce adjusted fonts. -- [ ] Phase 5: Replace Android-only font copies with clean generated outputs or generated platform registrations. -- [ ] Phase 6: Replace or isolate icon font tooling. -- [ ] Phase 7: Add CI validation for static metrics and screenshot fixtures where available. - -## Open Questions - -- [ ] Do we still have the licensed source package for the Mark Simonson font, or only the post-processed TTFs? -- [ ] Should generated fonts retain existing `Keybase` family naming for compatibility? -- [ ] Should Android move from `assets/fonts` to `res/font` XML registration for cleaner weight selection? -- [ ] Which exact visual target should strikethrough use: optical center of x-height, current desktop Chrome behavior, or a design-specified ratio? -- [ ] Should web font generation remain part of this repo, or is it obsolete? -- [ ] Do we want generated font binaries committed, or produced only by release tooling? - -## Definition Of Done - -- [ ] A fresh checkout can generate all font outputs from documented inputs. -- [ ] Static metric verification fails on accidental font table drift. -- [ ] The dev-only font debug page exists on iOS, Android, and Electron. -- [ ] Strikethrough, underline, line height, clipping, and weight selection are validated by fixtures. -- [ ] Android no longer depends on `fonts/android/fixes.py` or metric-divergent font copies. -- [ ] The old icon font tooling is either replaced or clearly isolated from text font tooling. diff --git a/plans/kb.ttf.md b/plans/kb.ttf.md new file mode 100644 index 000000000000..a429e699cd2d --- /dev/null +++ b/plans/kb.ttf.md @@ -0,0 +1,777 @@ +# kb.ttf Icon Font Build Pipeline + Icon Browser + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the `webfonts-generator` + `fontforge` pipeline for `kb.ttf` with pure Python using `fonttools`, and add a dev-only icon browser debug screen. + +**Architecture:** Add a `build-icon` subcommand to `shared/tools/fonts/font_tool.py` (same file, same pattern as `build-text`). The command reads SVG files from `shared/images/iconfont/`, converts paths to TrueType quadratic outlines using `fonttools` + `cu2quPen`, applies the same metrics that `fontforge` currently sets, and writes `shared/fonts/kb.ttf` plus `shared/fonts/android/kb.ttf`. A new `shared/settings/icons.tsx` debug screen shows all iconfont glyphs in a scrollable grid, gated behind `__DEV__`. + +**Tech Stack:** Python 3, `fonttools` 4.60.2 (already in `requirements.txt`), TypeScript/React Native for the debug screen. No new Python dependencies. + +--- + +## Background: current pipeline + +`yarn update-icon-font` runs `shared/desktop/yarn-helper/font.mts` which: +1. Uses `webfonts-generator` (Node.js) to combine the SVG files into a TTF +2. Calls `fontforge` via shell to apply OS/2, hhea, GASP metrics and shift glyphs vertically + +The new pipeline eliminates both `webfonts-generator` and `fontforge`, keeping the produced binary byte-for-byte equivalent in metrics (visual output will be identical). + +## Key constants (from `font.mts`) + +``` +FONT_HEIGHT = 1024 +DESCENT = FONT_HEIGHT / 16 = 64 +BASE_CHAR_CODE = 0xe900 +codepoint(counter) = BASE_CHAR_CODE + counter - 1 + +WIN_ASCENT = FONT_HEIGHT - DESCENT + 2 = 962 +WIN_DESCENT = DESCENT * 2 + 20 = 148 +TYPO_ASCENT = FONT_HEIGHT - DESCENT = 960 +TYPO_DESCENT = -DESCENT = -64 +TYPO_LINE_GAP = 0 +HHEA_ASCENT = WIN_ASCENT = 962 +HHEA_DESCENT = -WIN_DESCENT = -148 +GASP = {65535: 15} (all 4 bits: gridfit + dogray + symmetric) + +Glyph vertical shift (applied via coordinate transform during build): + All glyphs: shift Y by -64 (i.e., baseline = TYPO_ASCENT - FONT_HEIGHT = -64 below top) + 24-size glyphs: shift Y by additional -22 +``` + +## SVG coordinate transform + +The icon SVGs use `viewBox="0 0 W W"` (W = 8, 16, or 24). Font EM = 1024. Y-axis is flipped (SVG y-down, font y-up). + +For a glyph of grid size `W`: + +``` +scale = 1024.0 / W +x_font = x_svg * scale +y_font = (W - y_svg) * scale - DESCENT [for 8- and 16-size] +y_font = (W - y_svg) * scale - DESCENT - 22 [for 24-size only] +``` + +This is expressed as an affine matrix `(xx, xy, yx, yy, dx, dy)` for `TransformPen`: +``` +(scale, 0, 0, -scale, 0, W*scale - DESCENT) # 8 and 16 +(scale, 0, 0, -scale, 0, W*scale - DESCENT - 22) # 24 +``` + +Advance width for all glyphs = `W * scale = 1024`. + +--- + +## File map + +| Action | Path | +|--------|------| +| Modify | `shared/tools/fonts/font_tool.py` | +| Modify | `shared/fonts/manifest.json` | +| Modify | `shared/package.json` | +| Modify | `shared/fonts/README.md` | +| Create | `shared/settings/icons.tsx` | +| Modify | `shared/constants/settings.tsx` | +| Modify | `shared/settings/routes.tsx` | +| Modify | `shared/settings/root-phone.tsx` | +| Modify | `shared/settings/sub-nav/left-nav.tsx` | + +--- + +## Task 1: Add imports, constants, and SVG path parser to `font_tool.py` + +**Files:** +- Modify: `shared/tools/fonts/font_tool.py` + +- [ ] **Step 1: Add imports at the top of `font_tool.py`** + +After the existing `import glob, json, sys` block, add: + +```python +import re +import xml.etree.ElementTree as ET +``` + +- [ ] **Step 2: Add icon font constants after existing imports** + +```python +# --- Icon font build constants (match shared/desktop/yarn-helper/font.mts) --- +_ICON_FONT_HEIGHT = 1024 +_ICON_DESCENT = _ICON_FONT_HEIGHT // 16 # 64 +_ICON_BASE_CHAR_CODE = 0xe900 +_ICON_WIN_ASCENT = _ICON_FONT_HEIGHT - _ICON_DESCENT + 2 # 962 +_ICON_WIN_DESCENT = _ICON_DESCENT * 2 + 20 # 148 +_ICON_TYPO_ASCENT = _ICON_FONT_HEIGHT - _ICON_DESCENT # 960 +_ICON_TYPO_DESCENT = -_ICON_DESCENT # -64 +_ICON_HHEA_ASCENT = _ICON_WIN_ASCENT # 962 +_ICON_HHEA_DESCENT = -_ICON_WIN_DESCENT # -148 +_ICON_24_EXTRA_SHIFT = 22 +_ICON_FILENAME_RE = re.compile(r'^(\d+)-kb-iconfont-(.*?)-(\d+)\.svg$') +``` + +- [ ] **Step 3: Add SVG path parser function** + +This parser handles the subset of SVG path commands used by the icon SVGs (`M`, `L`, `H`, `V`, `C`, `Z` and their lowercase relative variants). It draws into a fonttools `SegmentPen`. + +```python +def _draw_svg_path(d: str, pen) -> None: + """Parse an SVG path `d` string and draw into a fonttools SegmentPen.""" + tokens = re.findall( + r'[MmLlHhVvCcSsQqTtZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', + d + ) + idx = 0 + cmd = '' + cx, cy = 0.0, 0.0 + contour_open = False + + def nums(n: int) -> list: + nonlocal idx + result = [float(tokens[idx + i]) for i in range(n)] + idx += n + return result + + def ensure_closed(): + nonlocal contour_open + if contour_open: + pen.endPath() + contour_open = False + + while idx < len(tokens): + t = tokens[idx] + if re.match(r'[A-Za-z]', t): + cmd = t + idx += 1 + + if cmd in ('M', 'm'): + ensure_closed() + x, y = nums(2) + if cmd == 'm': + x, y = cx + x, cy + y + pen.moveTo((x, y)) + contour_open = True + cx, cy = x, y + # Subsequent coords in an M sequence are implicit L/l + cmd = 'L' if cmd == 'M' else 'l' + elif cmd in ('L', 'l'): + x, y = nums(2) + if cmd == 'l': + x, y = cx + x, cy + y + pen.lineTo((x, y)) + cx, cy = x, y + elif cmd in ('H', 'h'): + x, = nums(1) + if cmd == 'h': + x = cx + x + pen.lineTo((x, cy)) + cx = x + elif cmd in ('V', 'v'): + y, = nums(1) + if cmd == 'v': + y = cy + y + pen.lineTo((cx, y)) + cy = y + elif cmd in ('C', 'c'): + x1, y1, x2, y2, x, y = nums(6) + if cmd == 'c': + x1, y1 = cx + x1, cy + y1 + x2, y2 = cx + x2, cy + y2 + x, y = cx + x, cy + y + pen.curveTo((x1, y1), (x2, y2), (x, y)) + cx, cy = x, y + elif cmd in ('S', 's'): + # Smooth cubic: x1/y1 is reflection of previous control point. + # We don't track the previous control point so treat as C with x1=cx,y1=cy. + x2, y2, x, y = nums(4) + if cmd == 's': + x2, y2 = cx + x2, cy + y2 + x, y = cx + x, cy + y + pen.curveTo((cx, cy), (x2, y2), (x, y)) + cx, cy = x, y + elif cmd in ('Z', 'z'): + if contour_open: + pen.closePath() + contour_open = False + else: + idx += 1 # unknown command, skip + + ensure_closed() +``` + +- [ ] **Step 4: Verify the parser handles the real SVG files** + +```bash +cd /path/to/client +python3 -c " +import sys; sys.path.insert(0, 'shared/tools/fonts') +from font_tool import _draw_svg_path +from fontTools.pens.recordingPen import RecordingPen +p = RecordingPen() +_draw_svg_path('M8 15.75C4.281 0.25 0 3.719.25 8Z', p) +print(p.value) +" +``` + +Expected: prints a list of pen operations ending with `('closePath', ())` + +- [ ] **Step 5: Commit** + +```bash +git add shared/tools/fonts/font_tool.py +git commit -m "font_tool: add SVG path parser for icon font build" +``` + +--- + +## Task 2: Add `_build_icon_glyph` and `cmd_build_icon` to `font_tool.py` + +**Files:** +- Modify: `shared/tools/fonts/font_tool.py` + +- [ ] **Step 1: Add the per-glyph builder function** + +```python +def _build_icon_glyph(svg_path: str, size: int) -> tuple: + """ + Parse one SVG icon file and return (TTGlyph, advance_width, lsb). + size is the icon grid size (8, 16, or 24). + """ + from fontTools.pens.ttGlyphPen import TTGlyphPen + from fontTools.pens.transformPen import TransformPen + from fontTools.pens.cu2quPen import Cu2QuPen + + scale = _ICON_FONT_HEIGHT / size + y_shift = size * scale - _ICON_DESCENT + if size == 24: + y_shift -= _ICON_24_EXTRA_SHIFT + + # Pen chain: SVG path commands → transform → cubic→quadratic → TTGlyph + tt_pen = TTGlyphPen(None) + cu2qu_pen = Cu2QuPen(tt_pen, max_err=1.0, reverse_direction=False) + transform_pen = TransformPen(cu2qu_pen, (scale, 0, 0, -scale, 0, y_shift)) + + ns = 'http://www.w3.org/2000/svg' + tree = ET.parse(svg_path) + root = tree.getroot() + + for elem in root.iter(f'{{{ns}}}path'): + d = elem.get('d', '').strip() + if d: + _draw_svg_path(d, transform_pen) + + advance_width = int(round(size * scale)) # = 1024 for all sizes + glyph = tt_pen.glyph(dropImpliedOnCurves=True) + lsb = glyph.xMin if glyph.numberOfContours != 0 else 0 + return glyph, advance_width, lsb +``` + +- [ ] **Step 2: Add `cmd_build_icon`** + +```python +def cmd_build_icon(args): + from fontTools.fontBuilder import FontBuilder + + manifest = json.loads(Path(args.manifest).read_text()) + repo_root = Path(args.manifest).resolve().parent.parent.parent + icon_cfg = manifest.get("iconFont", {}) + iconfont_dir = repo_root / "shared" / "images" / "iconfont" + + # Collect and sort SVG files by counter + entries = [] + for svg_file in sorted(iconfont_dir.glob("*.svg")): + m = _ICON_FILENAME_RE.match(svg_file.name) + if not m: + continue + counter, name, size_str = int(m.group(1)), m.group(2), int(m.group(3)) + entries.append((counter, name, size_str, svg_file)) + entries.sort(key=lambda e: e[0]) + + if not entries: + print("ERROR: no SVG files found", file=sys.stderr) + sys.exit(1) + + # Build glyph data + glyph_order = [".notdef"] + char_map: dict[int, str] = {} + glyph_data: dict = {} + h_metrics: dict = {} + + # .notdef: empty glyph + from fontTools.ttLib.tables._g_l_y_f import Glyph as TTGlyph + empty = TTGlyph() + empty.numberOfContours = 0 + empty.coordinates = [] + empty.flags = [] + empty.components = [] + glyph_data[".notdef"] = empty + h_metrics[".notdef"] = (_ICON_FONT_HEIGHT, 0) + + seen_counters: set[int] = set() + errors = 0 + for counter, name, size, svg_path in entries: + if counter in seen_counters: + print(f"ERROR: duplicate counter {counter} in {svg_path.name}", file=sys.stderr) + errors += 1 + continue + seen_counters.add(counter) + + glyph_name = f"uni{(_ICON_BASE_CHAR_CODE + counter - 1):04X}" + codepoint = _ICON_BASE_CHAR_CODE + counter - 1 + glyph_order.append(glyph_name) + char_map[codepoint] = glyph_name + + try: + glyph, adv, lsb = _build_icon_glyph(str(svg_path), size) + glyph_data[glyph_name] = glyph + h_metrics[glyph_name] = (adv, lsb) + except Exception as e: + print(f"ERROR building glyph for {svg_path.name}: {e}", file=sys.stderr) + errors += 1 + # insert empty glyph so glyph order stays consistent + glyph_data[glyph_name] = empty + h_metrics[glyph_name] = (_ICON_FONT_HEIGHT, 0) + + if errors: + print(f"build-icon: {errors} error(s)", file=sys.stderr) + sys.exit(1) + + # Assemble font + fb = FontBuilder(_ICON_FONT_HEIGHT, isTTF=True) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap(char_map) + fb.setupGlyf(glyph_data) + fb.setupHorizontalMetrics(h_metrics) + fb.setupHorizontalHeader( + ascent=_ICON_HHEA_ASCENT, + descent=_ICON_HHEA_DESCENT, + ) + fb.setupNameTable({ + "familyName": "kb", + "styleName": "Regular", + }) + fb.setupOs2( + sTypoAscender=_ICON_TYPO_ASCENT, + sTypoDescender=_ICON_TYPO_DESCENT, + sTypoLineGap=0, + usWinAscent=_ICON_WIN_ASCENT, + usWinDescent=_ICON_WIN_DESCENT, + fsType=0, + fsSelection=0, + ) + fb.setupPost(keepGlyphNames=False) + fb.setupGasp(gaspRange={65535: 15}) + + # Determine output paths from manifest iconFont config + outputs: list[str] = icon_cfg.get("outputs", [icon_cfg.get("output", "")]) + if isinstance(outputs, str): + outputs = [outputs] + + platform: str = getattr(args, 'platform', 'all') + android_output = icon_cfg.get("androidOutput", "") + + wrote = [] + if platform == "android": + if android_output: + out_path = repo_root / android_output + out_path.parent.mkdir(parents=True, exist_ok=True) + fb.font.save(str(out_path)) + wrote.append(str(out_path)) + else: + for rel in outputs: + if not rel: + continue + out_path = repo_root / rel + out_path.parent.mkdir(parents=True, exist_ok=True) + fb.font.save(str(out_path)) + wrote.append(str(out_path)) + + for w in wrote: + print(f" wrote {w}", file=sys.stderr) + print(f"build-icon: {len(entries)} glyphs, {len(wrote)} output(s)", file=sys.stderr) + + # Touch sentinel so webpack notices font change + sentinel = repo_root / "shared" / "fonts" / ".font-build-stamp" + sentinel.write_text(str(__import__('time').time()) + "\n") +``` + +- [ ] **Step 3: Register `cmd_build_icon` in `main()`** + +In the `main()` function, add after the `p_diff` block (before `args = parser.parse_args()`): + +```python + p_icon = sub.add_parser("build-icon", help="Build kb.ttf icon font from SVGs") + p_icon.add_argument("--manifest", required=True, metavar="FILE") + p_icon.add_argument("--platform", default="all", choices=["all", "android"], + help="Target platform (default: all; 'android' writes androidOutput)") + p_icon.set_defaults(func=cmd_build_icon) +``` + +- [ ] **Step 4: Commit** + +```bash +git add shared/tools/fonts/font_tool.py +git commit -m "font_tool: add build-icon command (SVG→TTF via fonttools)" +``` + +--- + +## Task 3: Update `manifest.json`, `package.json`, and `README.md` + +**Files:** +- Modify: `shared/fonts/manifest.json` +- Modify: `shared/package.json` +- Modify: `shared/fonts/README.md` + +- [ ] **Step 1: Update `manifest.json` — add outputs and androidOutput to iconFont** + +Replace the existing `"iconFont"` block: + +```json +"iconFont": { + "id": "kb", + "source": "shared/images/iconfont/*.svg", + "output": "shared/fonts/kb.ttf", + "outputs": ["shared/fonts/kb.ttf", "shared/fonts/ios/kb.ttf", "shared/fonts/electron/kb.ttf"], + "androidOutput": "shared/fonts/android/kb.ttf", + "generator": "shared/tools/fonts/font_tool.py" +} +``` + +Note: `"generator"` is informational only (was pointing to the old font.mts). The `"output"` key stays for backward compatibility with `cmd_snapshot_metrics`. + +- [ ] **Step 2: Add yarn scripts to `shared/package.json`** + +In the `scripts` section, after `"font:build-android"`: + +```json +"font:build-icon": "python3 tools/fonts/font_tool.py build-icon --manifest fonts/manifest.json", +"font:build-icon-android": "python3 tools/fonts/font_tool.py build-icon --manifest fonts/manifest.json --platform android", +``` + +- [ ] **Step 3: Add documentation to `shared/fonts/README.md`** + +In the "Build Commands" section, after `yarn font:build-android`: + +```markdown +Build icon font (kb.ttf) for all platforms (iOS, Electron) from SVGs: + +```sh +yarn font:build-icon +``` + +Build icon font for Android: + +```sh +yarn font:build-icon-android +``` +``` + +Also update the "Directory Layout" table to add: + +```markdown +| `shared/fonts/ios/` | iOS-specific built outputs (includes `kb.ttf`) | +``` + +And add a note under `## Open Questions` removing or resolving the web font question if appropriate (leave as-is if uncertain). + +- [ ] **Step 4: Run the build and verify output exists** + +```bash +cd shared +yarn font:build-icon +``` + +Expected stderr: lines like ` wrote .../shared/fonts/kb.ttf` then `build-icon: 193 glyphs, 3 output(s)` + +Check the file was written: +```bash +ls -la shared/fonts/kb.ttf shared/fonts/android/kb.ttf +``` + +Expected: both files exist with recent modification time and size > 20KB. + +- [ ] **Step 5: Inspect the built font and spot-check metrics** + +```bash +cd shared +yarn font:inspect --inputs fonts/kb.ttf | python3 -c " +import json, sys +d = json.load(sys.stdin)[0] +os2 = d['tables']['OS/2'] +hhea = d['tables']['hhea'] +gasp = d['tables'].get('gasp', {}) +print('WIN_ASCENT', os2['usWinAscent'], '== 962?', os2['usWinAscent'] == 962) +print('WIN_DESCENT', os2['usWinDescent'], '== 148?', os2['usWinDescent'] == 148) +print('TYPO_ASCENT', os2['sTypoAscender'], '== 960?', os2['sTypoAscender'] == 960) +print('HHEA_ASCENT', hhea['ascender'], '== 962?', hhea['ascender'] == 962) +print('GASP', gasp) +print('GLYPH_COUNT', d['glyphCount'], '>= 194?', d['glyphCount'] >= 194) +" +``` + +Expected: all comparisons print `True`, GASP shows `{\"65535\": 15}`, glyph count ≥ 194 (193 icons + .notdef). + +- [ ] **Step 6: Commit** + +```bash +git add shared/fonts/manifest.json shared/package.json shared/fonts/README.md +git commit -m "font: wire font:build-icon yarn scripts and manifest outputs" +``` + +--- + +## Task 4: Create `shared/settings/icons.tsx` icon browser + +**Files:** +- Create: `shared/settings/icons.tsx` + +This debug screen shows every `iconfont-*` entry from `iconMeta` in a scrollable grid. Each cell renders the glyph via `Kb.Icon` with the icon name below it. A search box filters by name. + +- [ ] **Step 1: Create `shared/settings/icons.tsx`** + +```tsx +// Dev-only icon browser. Gated by __DEV__ in nav and routes — never visible in production. +import * as Kb from '@/common-adapters' +import {iconMeta} from '@/common-adapters/icon.constants-gen.shared' +import type {IconType} from '@/common-adapters/icon.constants-gen.d' +import * as React from 'react' + +const iconfontTypes: ReadonlyArray = (Object.keys(iconMeta) as Array) + .filter(k => iconMeta[k].isFont) + .sort() + +const CELL_SIZE = 80 + +const IconCell = ({type}: {type: IconType}) => { + const name = type.replace(/^iconfont-/, '') + return ( + + + + {name} + + + ) +} + +const Icons = () => { + const [query, setQuery] = React.useState('') + const filtered = query + ? iconfontTypes.filter(t => t.includes(query.toLowerCase())) + : iconfontTypes + + return ( + + + + + {filtered.length} / {iconfontTypes.length} + + + + + {filtered.map(t => ( + + ))} + + + + ) +} + +const styles = Kb.Styles.styleSheetCreate(() => ({ + cell: { + height: CELL_SIZE, + padding: Kb.Styles.globalMargins.xtiny, + width: CELL_SIZE, + }, + cellLabel: { + color: Kb.Styles.globalColors.black_50, + marginTop: 2, + textAlign: 'center', + }, + count: { + color: Kb.Styles.globalColors.black_50, + marginLeft: Kb.Styles.globalMargins.small, + }, + grid: { + flexWrap: 'wrap', + padding: Kb.Styles.globalMargins.tiny, + }, + scroll: {flex: 1}, + searchRow: { + alignItems: 'center', + borderBottomColor: Kb.Styles.globalColors.black_10, + borderBottomWidth: 1, + padding: Kb.Styles.globalMargins.small, + }, +})) + +export default Icons +``` + +- [ ] **Step 2: Check that `Kb.SearchFilter` is the right component name** + +```bash +cd shared +grep -r 'SearchFilter\|searchFilter' common-adapters/index.tsx | head -5 +``` + +If `SearchFilter` is not exported from `@/common-adapters`, use `Kb.PlainInput` instead: + +```tsx + +``` + +And add to styles: +```ts +searchInput: { + borderColor: Kb.Styles.globalColors.black_10, + borderRadius: 4, + borderWidth: 1, + flex: 1, + padding: Kb.Styles.globalMargins.xtiny, +}, +``` + +- [ ] **Step 3: Check that `sizeType="Big"` exists on `Kb.Icon`** + +```bash +cd shared +grep -n 'sizeType\|Big' common-adapters/icon.tsx | head -10 +``` + +If `sizeType` isn't a valid prop, replace with `fontSize={24}`. + +- [ ] **Step 4: Run TypeScript check** + +```bash +cd shared +yarn tsc --noEmit 2>&1 | grep icons +``` + +Expected: no errors in `settings/icons.tsx` + +- [ ] **Step 5: Commit** + +```bash +git add shared/settings/icons.tsx +git commit -m "settings: add dev-only icon browser screen" +``` + +--- + +## Task 5: Register the icon browser in settings routes and nav + +**Files:** +- Modify: `shared/constants/settings.tsx` +- Modify: `shared/settings/routes.tsx` +- Modify: `shared/settings/root-phone.tsx` +- Modify: `shared/settings/sub-nav/left-nav.tsx` + +The pattern to follow exactly mirrors how `settingsTypographyTab` and `typography.tsx` are wired up. + +- [ ] **Step 1: Add constant to `shared/constants/settings.tsx`** + +After line 23 (`export const settingsTypographyTab = ...`): + +```ts +export const settingsIconsTab = 'settingsTabs.iconsTab' +``` + +- [ ] **Step 2: Add route to `shared/settings/routes.tsx`** + +The typography block is at lines 134–137 (the `__DEV__` guard inside `sharedNewRoutes`). Add an adjacent entry immediately after the typography entry, before the closing of the `__DEV__` spread: + +```ts +[Settings.settingsIconsTab]: { + getOptions: {title: 'Icons'}, + screen: React.lazy(async () => import('./icons')), +}, +``` + +Also at line 155 (the mobile-side `__DEV__` spread), add: + +```ts +[Settings.settingsIconsTab]: sharedNewRoutes[Settings.settingsIconsTab], +``` + +Read the exact lines first to match the indentation and structure before editing. + +- [ ] **Step 3: Add nav entry to `shared/settings/root-phone.tsx`** + +Find the `__DEV__` block around line 183 that adds the Typography nav item. Add an Icons entry immediately after it: + +```tsx +{__DEV__ && ( + navigateAppend({name: Settings.settingsIconsTab, params: {}})} + > + Icons + +)} +``` + +Read the Typography entry first and match its exact JSX structure. + +- [ ] **Step 4: Add nav entry to `shared/settings/sub-nav/left-nav.tsx`** + +Find the `{__DEV__ && ...}` block around line 127 that renders the Typography nav link. Add an adjacent Icons block immediately after it: + +```tsx +{__DEV__ && ( + +)} +``` + +Again read the Typography block first and match its exact JSX structure (the component name may differ from `SubNav`). + +- [ ] **Step 5: Lint and type-check** + +```bash +cd shared +yarn lint --quiet 2>&1 | grep -E 'icons|settings' +yarn tsc --noEmit 2>&1 | grep -E 'icons|settings' +``` + +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add shared/constants/settings.tsx shared/settings/routes.tsx \ + shared/settings/root-phone.tsx shared/settings/sub-nav/left-nav.tsx +git commit -m "settings: register dev-only icon browser in routes and nav" +``` + +--- + +## Self-review + +**Spec coverage:** +- [x] Python build for `shared/fonts/kb.ttf` — Task 2 +- [x] Python build for `shared/fonts/android/kb.ttf` — Tasks 2–3 +- [x] Matches existing metrics (OS/2, hhea, GASP) — constants in Task 1, applied in Task 2 +- [x] Yarn scripts `font:build-icon` / `font:build-icon-android` — Task 3 +- [x] README updated — Task 3 +- [x] Icon browser debug screen — Task 4 +- [x] Screen registered behind `__DEV__` guard — Task 5 + +**Known tuning step (not in plan tasks):** If rendered icons look inverted or filled incorrectly, change `reverse_direction=False` to `reverse_direction=True` in `Cu2QuPen(...)` in `_build_icon_glyph`. This controls whether TrueType contour winding is reversed during cubic→quadratic conversion. The correct value depends on how the source SVGs specify fill direction. + +**Placeholder check:** None — all steps include exact code or exact commands. diff --git a/plans/split-ts.md b/plans/split-ts.md new file mode 100644 index 000000000000..1f633d8ace06 --- /dev/null +++ b/plans/split-ts.md @@ -0,0 +1,557 @@ +# Split TypeScript Config (Desktop vs Native) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the single `shared/tsconfig.json` with per-platform configs so that `dom` APIs are forbidden in native code, native-only APIs are forbidden in desktop code, and the `.d.ts` type stubs are eliminated in favor of auto-generated `paths` that resolve directly to platform implementation files. + +**Architecture:** A generator script (`shared/tools/gen-ts-paths.mjs`) scans the filesystem for modules that have `.desktop.tsx`/`.native.tsx` variants but no plain `.tsx`, then writes `tsconfig.paths.desktop.json` and `tsconfig.paths.native.json`. Each platform tsconfig extends `tsconfig.base.json` plus its generated paths file (TypeScript 5.0 array `extends`). The generated files are gitignored and regenerated before every type-check. Because TypeScript `paths` only intercept non-relative import specifiers, this works cleanly for the `@/*` alias style used throughout the codebase. + +**Tech Stack:** TypeScript (`tsgo`), tsconfig `extends` array (TS 5.0+), Node ESM script, `yarn` scripts + +--- + +## File Map + +| File | Action | +|---|---| +| `shared/tsconfig.json` | Modify → editor fallback, extends desktop | +| `shared/tsconfig.base.json` | Create — platform-agnostic compiler options | +| `shared/tsconfig.desktop.json` | Create — desktop lib/types, extends base + generated paths | +| `shared/tsconfig.native.json` | Create — native lib/types, extends base + generated paths | +| `shared/tsconfig.paths.desktop.json` | Generated (gitignored) — `@/foo` → `./foo.desktop` mappings | +| `shared/tsconfig.paths.native.json` | Generated (gitignored) — `@/foo` → `./foo.native` mappings | +| `shared/tools/gen-ts-paths.mjs` | Create — generator script | +| `shared/package.json` | Modify `tsc` script to run generator then both platform configs | +| `shared/**/*.d.ts` | Delete — replaced by generated paths (keep only non-platform stubs) | +| `.gitignore` | Modify — add generated paths files | + +--- + +### Task 1: Create tsconfig.base.json + +Extract platform-agnostic options from the current `shared/tsconfig.json`. `lib`, `types`, `outDir`, and `tsBuildInfoFile` are intentionally omitted — each platform config sets those. + +**Files:** +- Create: `shared/tsconfig.base.json` + +- [ ] **Step 1: Create the file** + +```json +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "allowJs": false, + "allowImportingTsExtensions": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "checkJs": false, + "incremental": true, + "isolatedModules": true, + "jsx": "preserve", + "module": "preserve", + "moduleResolution": "bundler", + "exactOptionalPropertyTypes": false, + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext" + }, + "watchOptions": { + "watchFile": "useFsEvents", + "watchDirectory": "useFsEvents", + "fallbackPolling": "dynamicPriority", + "synchronousWatchDirectory": true, + "excludeDirectories": ["**/node_modules", "./desktop/dist", "./desktop/build"] + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd shared && git add tsconfig.base.json +git commit -m "build: add tsconfig.base.json" +``` + +--- + +### Task 2: Write the paths generator script + +This script scans `shared/` for modules that have only platform-specific variants (e.g. `foo.desktop.tsx` + `foo.native.tsx` but no `foo.tsx`) and writes two generated tsconfig paths files. + +**Files:** +- Create: `shared/tools/gen-ts-paths.mjs` + +- [ ] **Step 1: Create the script** + +```js +#!/usr/bin/env node +import {readdirSync, statSync, writeFileSync, existsSync} from 'fs' +import {join, relative, dirname, basename, extname} from 'path' +import {fileURLToPath} from 'url' + +const sharedDir = join(fileURLToPath(import.meta.url), '..', '..') + +const PLATFORMS = ['desktop', 'native'] +const EXTENSIONS = ['.tsx', '.ts', '.mts'] +const SKIP_DIRS = new Set(['node_modules', '.tsOuts', 'dist', 'build', 'tools']) + +function walk(dir, results = []) { + for (const entry of readdirSync(dir, {withFileTypes: true})) { + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) walk(join(dir, entry.name), results) + } else { + results.push(join(dir, entry.name)) + } + } + return results +} + +function stripExt(file) { + for (const ext of EXTENSIONS) { + if (file.endsWith(ext)) return file.slice(0, -ext.length) + } + return file +} + +// Collect all files +const allFiles = new Set(walk(sharedDir)) + +// For each platform, find modules that have a platform variant but no plain variant +function buildPaths(platform) { + const paths = {'@/*': ['./*']} + + for (const file of allFiles) { + for (const ext of EXTENSIONS) { + const suffix = `.${platform}${ext}` + if (!file.endsWith(suffix)) continue + + // e.g. /shared/styles/index.desktop.tsx → base = /shared/styles/index + const base = file.slice(0, -suffix.length) + + // Check if a plain variant exists (foo.tsx / foo.ts / foo.mts) + const hasPlain = EXTENSIONS.some(e => existsSync(base + e)) + if (hasPlain) continue + + // Generate the @/* key and value + const rel = relative(sharedDir, base) // e.g. "styles/index" + const key = `@/${rel}` // e.g. "@/styles/index" + const value = `./${rel}.${platform}` // e.g. "./styles/index.desktop" + paths[key] = [value] + } + } + + return paths +} + +for (const platform of PLATFORMS) { + const paths = buildPaths(platform) + const out = {compilerOptions: {paths}} + const outFile = join(sharedDir, `tsconfig.paths.${platform}.json`) + writeFileSync(outFile, JSON.stringify(out, null, 2) + '\n') + console.log(`wrote tsconfig.paths.${platform}.json (${Object.keys(paths).length - 1} platform paths)`) +} +``` + +- [ ] **Step 2: Make it executable and run it** + +```bash +cd shared && chmod +x tools/gen-ts-paths.mjs && node tools/gen-ts-paths.mjs +``` + +Expected output (numbers will vary): +``` +wrote tsconfig.paths.desktop.json (47 platform paths) +wrote tsconfig.paths.native.json (47 platform paths) +``` + +- [ ] **Step 3: Spot-check the output** + +```bash +head -30 shared/tsconfig.paths.desktop.json +``` + +Expected to see entries like: +```json +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"], + "@/styles/index": ["./styles/index.desktop"], + "@/engine/index": ["./engine/index.desktop"], + "@/common-adapters/animation": ["./common-adapters/animation.desktop"], + ... + } + } +} +``` + +- [ ] **Step 4: Commit the script (not the generated files)** + +```bash +cd shared && git add tools/gen-ts-paths.mjs +git commit -m "build: add gen-ts-paths.mjs to generate per-platform tsconfig paths" +``` + +--- + +### Task 3: Gitignore the generated paths files + +**Files:** +- Modify: root `.gitignore` or `shared/.gitignore` + +- [ ] **Step 1: Check which gitignore to update** + +```bash +ls /Users/chrisnojima/go/src/github.com/keybase/client/shared/.gitignore 2>/dev/null && echo "shared gitignore exists" || echo "use root" +``` + +- [ ] **Step 2: Add entries** + +Add to whichever `.gitignore` covers `shared/`: +``` +shared/tsconfig.paths.desktop.json +shared/tsconfig.paths.native.json +shared/.tsOuts/ +``` + +- [ ] **Step 3: Commit** + +```bash +git add .gitignore +git commit -m "chore: gitignore generated tsconfig paths files and tsOuts" +``` + +--- + +### Task 4: Create tsconfig.desktop.json + +**Files:** +- Create: `shared/tsconfig.desktop.json` + +- [ ] **Step 1: Ensure paths files are generated** + +```bash +cd shared && node tools/gen-ts-paths.mjs +``` + +- [ ] **Step 2: Create the file** + +The `extends` array is processed left-to-right; later entries win on conflicts. The generated paths file comes last so its `paths` (which includes `@/*`) fully replaces the base's empty paths. + +```json +{ + "extends": ["./tsconfig.base.json", "./tsconfig.paths.desktop.json"], + "compilerOptions": { + "lib": ["ESNext", "dom"], + "types": ["jest", "node", "webpack-env"], + "outDir": "./.tsOuts/.tsOut-desktop/emit", + "tsBuildInfoFile": "./.tsOuts/.tsOut-desktop/cache" + }, + "include": ["./**/*.mts", "./**/*.mjs", "./**/*.ts", "./**/*.tsx"], + "exclude": [ + "**/node_modules", + "./desktop/dist", + "./desktop/build", + "./**/*.native.tsx", + "./**/*.native.ts", + "./**/*.android.tsx", + "./**/*.android.ts", + "./**/*.ios.tsx", + "./**/*.ios.ts", + "./common-adapters/icon.constants-gen.desktop.tsx", + "./common-adapters/icon.constants-gen.native.tsx", + "./common-adapters/icon.constants-gen.shared.tsx" + ] +} +``` + +- [ ] **Step 3: Run desktop type-check** + +```bash +cd shared && ./node_modules/.bin/tsgo --project tsconfig.desktop.json 2>&1 | tee /tmp/desktop-ts-errors.txt | wc -l +``` + +Expected: same error count as the current `yarn tsc` (this is the baseline, desktop is the superset). + +- [ ] **Step 4: Commit** + +```bash +cd shared && git add tsconfig.desktop.json +git commit -m "build: add tsconfig.desktop.json" +``` + +--- + +### Task 5: Create tsconfig.native.json + +**Files:** +- Create: `shared/tsconfig.native.json` + +- [ ] **Step 1: Create the file** + +No `dom` in lib, no `webpack-env` in types, excludes all `*.desktop.*` and the whole `./desktop/` directory. + +```json +{ + "extends": ["./tsconfig.base.json", "./tsconfig.paths.native.json"], + "compilerOptions": { + "lib": ["ESNext"], + "types": ["jest", "node"], + "outDir": "./.tsOuts/.tsOut-native/emit", + "tsBuildInfoFile": "./.tsOuts/.tsOut-native/cache" + }, + "include": ["./**/*.mts", "./**/*.mjs", "./**/*.ts", "./**/*.tsx"], + "exclude": [ + "**/node_modules", + "./desktop", + "./**/*.desktop.tsx", + "./**/*.desktop.ts", + "./common-adapters/icon.constants-gen.desktop.tsx", + "./common-adapters/icon.constants-gen.native.tsx", + "./common-adapters/icon.constants-gen.shared.tsx" + ] +} +``` + +- [ ] **Step 2: Run native type-check and collect errors** + +```bash +cd shared && ./node_modules/.bin/tsgo --project tsconfig.native.json 2>&1 | tee /tmp/native-ts-errors.txt | wc -l +cat /tmp/native-ts-errors.txt | head -80 +``` + +There will be errors — some from native files accidentally using DOM APIs, and some from `.d.ts` stubs that the native paths haven't replaced yet. Note the count. + +- [ ] **Step 3: Commit the config** + +```bash +cd shared && git add tsconfig.native.json +git commit -m "build: add tsconfig.native.json (errors to fix in subsequent tasks)" +``` + +--- + +### Task 6: Delete .d.ts stubs covered by generated paths + +Now that `@/*` imports resolve to the real platform files via paths, the corresponding `.d.ts` stubs are redundant. Delete them platform-split ones; keep any that cover non-platform-split things (e.g. `globals.d.ts`, `css.d.ts`). + +**Files:** +- Delete: platform-split `.d.ts` stubs (those next to `.desktop.tsx`/`.native.tsx` pairs) +- Keep: `globals.d.ts`, `css.d.ts`, `local-debug.d.ts`, and any stub for a module with no platform files + +- [ ] **Step 1: Identify which stubs to delete** + +```bash +cd shared && node - <<'EOF' +import {readdirSync, existsSync} from 'fs' +import {join, dirname, basename} from 'path' +import {fileURLToPath} from 'url' +import {execSync} from 'child_process' + +const files = execSync('find . -name "*.d.ts" -not -path "*/node_modules/*"', {encoding: 'utf8'}) + .trim().split('\n') + +for (const f of files) { + const base = f.replace(/\.d\.ts$/, '') + const hasDesktop = existsSync(base + '.desktop.tsx') || existsSync(base + '.desktop.ts') + const hasNative = existsSync(base + '.native.tsx') || existsSync(base + '.native.ts') + if (hasDesktop || hasNative) console.log('DELETE:', f) + else console.log('KEEP: ', f) +} +EOF +``` + +- [ ] **Step 2: Delete the identified stubs** + +Run the deletions from the output of Step 1. Example pattern: +```bash +cd shared && rm \ + styles/index.d.ts \ + engine/index.d.ts \ + engine/index.platform.d.ts \ + engine/session.d.ts \ + common-adapters/animation.d.ts \ + # ... all DELETE entries from Step 1 +``` + +- [ ] **Step 3: Run both type-checks to see remaining errors** + +```bash +cd shared && node tools/gen-ts-paths.mjs && \ + ./node_modules/.bin/tsgo --project tsconfig.desktop.json 2>&1 | tee /tmp/desktop-after-delete.txt | wc -l && \ + ./node_modules/.bin/tsgo --project tsconfig.native.json 2>&1 | tee /tmp/native-after-delete.txt | wc -l +``` + +- [ ] **Step 4: Commit deletions** + +```bash +cd shared && git add -A +git commit -m "build: delete .d.ts stubs replaced by generated tsconfig paths" +``` + +--- + +### Task 7: Fix remaining type errors + +Work through errors in `/tmp/desktop-after-delete.txt` and `/tmp/native-after-delete.txt`. + +**Files:** Whichever source files surface errors. + +Two categories of errors to expect: + +**A) Native file uses DOM API** — e.g. `window`, `document`, `HTMLElement`, CSS properties like `cursor`: + +```tsx +// Before (in a *.native.tsx file): +const el = document.getElementById('foo') + +// After: this is a native file — use the RN equivalent or remove +// Native doesn't have a DOM; find the React Native alternative +``` + +**B) Import of a module whose stub was deleted but paths didn't cover it** (rare: within-directory relative import of a platform-split module): + +```tsx +// Before: +import Foo from './some-split-module' // relative import — paths don't apply + +// After option 1: convert to @/* alias so paths can resolve it +import Foo from '@/path/to/some-split-module' + +// After option 2: keep a minimal .d.ts stub for this specific file only +``` + +**C) Type from a deleted stub that was platform-specific** — if the `.d.ts` stub exported types that differ between platforms, the real implementation files may expose those differences. Align the callers to the real types. + +- [ ] **Step 1: Fix desktop errors from /tmp/desktop-after-delete.txt** + +```bash +cat /tmp/desktop-after-delete.txt +``` + +Fix each error. Re-run after each file fixed: +```bash +cd shared && ./node_modules/.bin/tsgo --project tsconfig.desktop.json 2>&1 | wc -l +``` + +- [ ] **Step 2: Fix native errors from /tmp/native-after-delete.txt** + +```bash +cat /tmp/native-after-delete.txt +``` + +Fix each error. Re-run after each file fixed: +```bash +cd shared && ./node_modules/.bin/tsgo --project tsconfig.native.json 2>&1 | wc -l +``` + +- [ ] **Step 3: Verify both pass clean** + +```bash +cd shared && node tools/gen-ts-paths.mjs && \ + ./node_modules/.bin/tsgo --project tsconfig.desktop.json 2>&1 | wc -l && \ + ./node_modules/.bin/tsgo --project tsconfig.native.json 2>&1 | wc -l +``` + +Expected: `0` on both lines. + +- [ ] **Step 4: Commit fixes** + +```bash +cd shared && git add -p +git commit -m "fix: resolve type errors surfaced by per-platform tsconfig split" +``` + +--- + +### Task 8: Update tsconfig.json to be the editor fallback + +Editors pick up `shared/tsconfig.json`. Point it at the desktop config (broadest types, includes `dom`). + +**Files:** +- Modify: `shared/tsconfig.json` + +- [ ] **Step 1: Replace the file content** + +```json +{ + "extends": "./tsconfig.desktop.json" +} +``` + +- [ ] **Step 2: Verify** + +```bash +cd shared && node tools/gen-ts-paths.mjs && ./node_modules/.bin/tsgo --project tsconfig.json 2>&1 | wc -l +``` + +Expected: `0`. + +- [ ] **Step 3: Commit** + +```bash +cd shared && git add tsconfig.json +git commit -m "build: tsconfig.json is editor fallback (extends tsconfig.desktop.json)" +``` + +--- + +### Task 9: Update package.json tsc script + +`yarn tsc` must regenerate paths then check both platforms. + +**Files:** +- Modify: `shared/package.json` + +- [ ] **Step 1: Update the script** + +Find: +```json +"tsc": "./node_modules/.bin/tsgo --project ./tsconfig.json", +``` + +Replace with: +```json +"tsc": "node ./tools/gen-ts-paths.mjs && ./node_modules/.bin/tsgo --project ./tsconfig.desktop.json && ./node_modules/.bin/tsgo --project ./tsconfig.native.json", +``` + +- [ ] **Step 2: Run the full script end-to-end** + +```bash +cd shared && yarn tsc +``` + +Expected: exits 0, both platform checks pass after path generation. + +- [ ] **Step 3: Commit** + +```bash +cd shared && git add package.json +git commit -m "build: yarn tsc generates paths and checks desktop + native configs" +``` + +--- + +## Self-Review Checklist + +- [x] **Generator script** scans filesystem and writes both paths files, always including `@/*` +- [x] **tsconfig.base.json** has all platform-agnostic options, no lib/types +- [x] **tsconfig.desktop.json** adds `dom`, `webpack-env`, excludes `.native.*` files +- [x] **tsconfig.native.json** drops `dom`/`webpack-env`, excludes `.desktop.*` and `./desktop/` +- [x] **Generated paths files** are gitignored +- [x] **Platform-split .d.ts stubs** deleted; non-platform stubs (`globals.d.ts` etc.) kept +- [x] **tsconfig.json** is editor fallback extending desktop +- [x] **yarn tsc** runs generator then both platform configs +- [x] **Relative-import limitation documented**: paths only intercept `@/*` imports; any within-directory relative import of a split module needs a stub or conversion to `@/*` diff --git a/plans/todo.md b/plans/todo.md index a5422a55ba1d..11744e7d8cd0 100644 --- a/plans/todo.md +++ b/plans/todo.md @@ -1,5 +1,7 @@ any leftover zustand store -fix fonts / strikethrought etc +ios bg colors wrong +ios push to convo broken +ts split maybe legend list for chat thread desktop legend list for chat thread native update deps diff --git a/rnmodules/react-native-kb/android/build.gradle b/rnmodules/react-native-kb/android/build.gradle index 727944b90e8c..02df114634b7 100644 --- a/rnmodules/react-native-kb/android/build.gradle +++ b/rnmodules/react-native-kb/android/build.gradle @@ -128,7 +128,6 @@ dependencies { implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "com.google.firebase:firebase-messaging:24.0.1" - implementation "me.leolin:ShortcutBadger:1.1.22@aar" implementation project(':keybaselib') implementation "androidx.media3:media3-transformer:1.4.1" implementation "androidx.media3:media3-effect:1.4.1" diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt index ff24c10fb3ba..62fb7a73ed96 100644 --- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt +++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt @@ -51,7 +51,6 @@ import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import keybase.Keybase -import me.leolin.shortcutbadger.ShortcutBadger import keybase.Keybase.readArr import keybase.Keybase.version import keybase.Keybase.writeArr @@ -453,7 +452,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T @ReactMethod override fun setApplicationIconBadgeNumber(badge: Double) { - ShortcutBadger.applyCount(reactContext, badge.toInt()) + // Android manages badge counts automatically via notification channels. } @ReactMethod diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt index 27ba8d29eef0..1eed346fdc0b 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KBPushNotifier.kt @@ -21,7 +21,6 @@ import androidx.core.app.RemoteInput import androidx.core.graphics.drawable.IconCompat import keybase.ChatNotification import keybase.PushNotifier -import me.leolin.shortcutbadger.ShortcutBadger import java.io.BufferedInputStream import java.io.IOException import java.net.HttpURLConnection @@ -157,13 +156,11 @@ class KBPushNotifier internal constructor(private val context: Context, private io.keybase.ossifrage.modules.NativeLogger.error("KBPushNotifier.displayChatNotification2 notifications are disabled!") return } - val notification = builder.build() - notificationManager.notify(chatNotification.convID, 0, notification) - // Apply badge count now that Go has confirmed this notification is for the - // active account (targetUID check passed in HandleBackgroundNotification). if (chatNotification.badgeCount >= 0) { - ShortcutBadger.applyCount(context, chatNotification.badgeCount.toInt()) + builder.setNumber(chatNotification.badgeCount.toInt()) } + val notification = builder.build() + notificationManager.notify(chatNotification.convID, 0, notification) } catch (e: Exception) { io.keybase.ossifrage.modules.NativeLogger.error("KBPushNotifier.displayChatNotification2 exception: " + e.message) } diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt index c8f6d102ba36..a482e9b3fcc8 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/KeybasePushNotificationListenerService.kt @@ -16,7 +16,6 @@ import io.keybase.ossifrage.MainActivity.Companion.setupKBRuntime import io.keybase.ossifrage.modules.NativeLogger import keybase.Keybase import com.reactnativekb.KbModule -import me.leolin.shortcutbadger.ShortcutBadger import org.json.JSONArray import org.json.JSONObject @@ -149,12 +148,6 @@ class KeybasePushNotificationListenerService : FirebaseMessagingService() { goProcessingSucceeded = false } - // For silent pushes the notifier is null so DisplayChatNotification is - // never called and KBPushNotifier cannot apply the badge. Apply it here - // after Go has validated the target UID, mirroring Pusher.swift on iOS. - if (goProcessingSucceeded && dontNotify && n.badgeCount >= 0) { - ShortcutBadger.applyCount(applicationContext, n.badgeCount) - } val isReactNativeRunning = try { com.reactnativekb.KbModule.isReactNativeRunning() diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index f0570c059f7f..4812f2e4079c 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -13,9 +13,6 @@ import android.provider.MediaStore import android.provider.Settings import android.util.Log import android.view.KeyEvent -import android.view.View -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat import androidx.core.content.IntentCompat import android.webkit.MimeTypeMap import com.facebook.react.ReactActivity @@ -89,21 +86,6 @@ class MainActivity : ReactActivity() { updateIsUsingHardwareKeyboard() scheduleHandleIntent() - - // edgeToEdgeEnabled=true in gradle.properties causes RN to call - // WindowCompat.setDecorFitsSystemWindows(false) on all API levels, which breaks - // adjustResize. Manually apply insets so the keyboard pushes content up. - val rootView = findViewById(android.R.id.content) - ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets -> - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val sysBarInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - rootView.setPadding( - sysBarInsets.left, - sysBarInsets.top, - sysBarInsets.right, - maxOf(imeInsets.bottom, sysBarInsets.bottom)) - insets - } } override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { diff --git a/shared/constants/settings.tsx b/shared/constants/settings.tsx index 1bd0f9a1387d..95bf96ef8785 100644 --- a/shared/constants/settings.tsx +++ b/shared/constants/settings.tsx @@ -20,6 +20,8 @@ export const settingsLogOutTab = 'settingsTabs.logOutTab' export const settingsUpdatePaymentTab = 'settingsTabs.updatePaymentTab' export const settingsWalletsTab = 'settingsTabs.walletsTab' export const settingsContactsTab = 'settingsTabs.contactsTab' +export const settingsTypographyTab = 'settingsTabs.typographyTab' +export const settingsIconsTab = 'settingsTabs.iconsTab' export type SettingsTab = | typeof settingsAccountTab diff --git a/shared/desktop/renderer/style.css b/shared/desktop/renderer/style.css index 94f8893d8760..2e2f8c2af575 100644 --- a/shared/desktop/renderer/style.css +++ b/shared/desktop/renderer/style.css @@ -134,7 +134,7 @@ table { /* KB custom font */ @font-face { font-family: 'kb'; - src: url('../../fonts/kb.ttf') format('truetype'); + src: url('../../fonts/electron/kb.ttf') format('truetype'); font-weight: normal; font-style: normal; font-display: block; @@ -144,14 +144,14 @@ table { @font-face { font-family: 'Source Code Pro'; font-weight: 500; - src: url('../../fonts/SourceCodePro-Medium.ttf') format('truetype'); + src: url('../../fonts/electron/SourceCodePro-Medium.ttf') format('truetype'); font-display: block; } @font-face { font-family: 'Source Code Pro'; font-weight: 600; - src: url('../../fonts/SourceCodePro-Semibold.ttf') format('truetype'); + src: url('../../fonts/electron/SourceCodePro-Semibold.ttf') format('truetype'); font-display: block; } /* End Source Code Pro */ @@ -160,7 +160,7 @@ table { @font-face { font-family: 'Keybase'; font-weight: 500; - src: url('../../fonts/keybase-medium.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-medium.ttf') format('truetype'); font-display: block; } @@ -168,14 +168,14 @@ table { font-family: 'Keybase'; font-weight: 500; font-style: italic; - src: url('../../fonts/keybase-medium-italic.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-medium-italic.ttf') format('truetype'); font-display: block; } @font-face { font-family: 'Keybase'; font-weight: 600; - src: url('../../fonts/keybase-semibold.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-semibold.ttf') format('truetype'); font-display: block; } @@ -183,14 +183,14 @@ table { font-family: 'Keybase'; font-weight: 600; font-style: italic; - src: url('../../fonts/keybase-semibold-italic.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-semibold-italic.ttf') format('truetype'); font-display: block; } @font-face { font-family: 'Keybase'; font-weight: 700; - src: url('../../fonts/keybase-bold.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-bold.ttf') format('truetype'); font-display: block; } @@ -198,14 +198,14 @@ table { font-family: 'Keybase'; font-weight: 700; font-style: italic; - src: url('../../fonts/keybase-bold-italic.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-bold-italic.ttf') format('truetype'); font-display: block; } @font-face { font-family: 'Keybase'; font-weight: 800; - src: url('../../fonts/keybase-extrabold.ttf') format('truetype'); + src: url('../../fonts/electron/keybase-extrabold.ttf') format('truetype'); font-display: block; } /* End Keybase */ diff --git a/shared/desktop/webpack.config.mts b/shared/desktop/webpack.config.mts index 861ae29c5119..187ee2fd1ea8 100644 --- a/shared/desktop/webpack.config.mts +++ b/shared/desktop/webpack.config.mts @@ -339,6 +339,7 @@ const config = (_: unknown, {mode}: {mode?: 'development' | 'none' | 'production type: 'filesystem', buildDependencies: { config: [configPath, babelConfigPath], + fonts: [path.resolve(__dirname, '../fonts/.font-build-stamp')], }, }, context: rootDir, diff --git a/shared/desktop/yarn-helper/font.mts b/shared/desktop/yarn-helper/font.mts index 62d7e9c2bad9..d2465b400096 100644 --- a/shared/desktop/yarn-helper/font.mts +++ b/shared/desktop/yarn-helper/font.mts @@ -3,22 +3,11 @@ import fs from 'fs' import path from 'path' import {execSync} from 'child_process' import prettier from 'prettier' -import crypto from 'crypto' -import {createRequire} from 'node:module' import {fileURLToPath} from 'node:url' -const require = createRequire(import.meta.url) const __dirname = path.dirname(fileURLToPath(import.meta.url)) const commands = { - 'update-icon-font': { - code: () => updateIconFont(false), - help: 'Update our font sizes automatically', - }, - 'update-web-font': { - code: () => updateIconFont(true), - help: 'Update our web font automatically', - }, 'update-icon-constants': { code: updateIconConstants, help: 'Update icon.constants-gen.tsx and icon.css with new/removed files', @@ -34,9 +23,6 @@ const paths = { iconPng: path.resolve(__dirname, '../../images/icons'), illustrationPng: path.resolve(__dirname, '../../images/illustrations'), releasePng: path.resolve(__dirname, '../../images/releases'), - fonts: path.resolve(__dirname, '../../fonts'), - webFonts: path.resolve(__dirname, '../../fonts-for-web'), - webFontsCss: path.resolve(__dirname, '../../fonts-for-web/fonts_custom.styl'), iconConstants: path.resolve(__dirname, '../../common-adapters/icon.constants-gen.shared.tsx'), iconConstantsdts: path.resolve(__dirname, '../../common-adapters/icon.constants-gen.d.ts'), iconCss: path.resolve(__dirname, '../../common-adapters/icon.css'), @@ -49,9 +35,6 @@ const pngAssetDirPaths = [ {assetDirPath: paths.releasePng, insertFn: insertReleaseAssets}, ] -const fontHeight = 1024 -const descentFraction = 16 // Source: https://icomoon.io/#docs/font-metrics -const descent = fontHeight / descentFraction const baseCharCode = 0xe900 const iconfontRegex = /^(\d+)-kb-iconfont-(.*)-(\d+).svg$/ @@ -100,206 +83,6 @@ const getSvgNames = (skipUnmatchedFile: boolean) => { .sort((x, y) => x.counter - y.counter) } -const getSvgPaths = (skipUnmatchedFile: boolean) => - getSvgNames(skipUnmatchedFile).map(i => path.resolve(paths.iconfont, i.filePath)) - -/* - * This function will read all of the SVG files specified above, and generate a - * single ttf iconfont from the svgs. webfonts-generator will write the file to - * `dest`. - * - * For config options: https://github.com/sunflowerdeath/webfonts-generator - */ -type FontResult = {ttf: string; woff: string; svg: string} -function updateIconFont(web: boolean) { - if (!web) { - // Check if fontforge is installed, required to generate the font - try { - execSync('fontforge') - } catch (error_) { - const error = error_ as {message?: string} - const message = String(error.message) - if (message.includes('not found')) { - throw new Error( - 'FontForge is required to generate the icon font. Run `yarn`, install FontForge CLI globally, and try again.', - {cause: error_} - ) - } - if (error_ instanceof Error) { - throw error_ - } - throw new Error(message, {cause: error_}) - } - } - - let webfontsGenerator: (...a: Array) => void - try { - webfontsGenerator = require('webfonts-generator') as typeof webfontsGenerator - } catch (e) { - console.error('\n\n\n\n>> Web fonts generation is optional, install manually to install it << \n\n\n') - throw e - } - const svgFilePaths = getSvgPaths(true /* print skipped */) - const svgFilenames = getSvgNames(false /* print skipped */) - /* - * NOTE: Since icon counters can be non-sequential, we need to tell our font generator which codepoint to use for each icon. - * This is done by setting `codepoints` object where the keys are character codes (hexidecimal) and the values are icon names - * - * { [name of svg file]: charCode } - * - * Example - * { "127-kb-iconfont-nav-2-files-24": "0xe97e" } - */ - const seenCounters = new Set() - const codepointsMap = svgFilenames.reduce((pointsMap, {counter, filePath}) => { - // Character code value converted from decimal to hexidecimal - const charCodeHex = computeCounter(counter).toString(16) - const {name} = path.parse(filePath) - if (seenCounters.has(counter)) { - throw new Error(`There are two SVGs with the same index number ${counter}`) - } - seenCounters.add(counter) - return { - ...pointsMap, - [name]: `0x${charCodeHex}`, - } - }, {}) - - if (web) { - try { - fs.mkdirSync(paths.webFonts) - } catch {} - } - - webfontsGenerator( - { - // An intermediate svgfont will be generated and then converted to TTF by webfonts-generator - types: web ? ['svg', 'ttf', 'woff'] : ['ttf'], - files: svgFilePaths, - dest: paths.fonts, - startCodepoint: baseCharCode, - fontName: 'kb', - classSelector: 'icon-kb', - css: false, - html: false, - writeFiles: false, - codepoints: codepointsMap, - formatOptions: { - ttf: {ts: 0, version: `${Date.now()}.0`}, // MUST use a unique version else windows installer does the WRONG THING - // Setting descent to zero on font generation will prevent the final - // glyphs from being shifted down - svg: { - fontHeight, - descent: 0, - }, - }, - }, - (error: unknown, result: FontResult) => - error ? fontsGeneratedError(error) : fontsGeneratedSuccess(web, result) - ) - console.log('Created new font') -} - -const fontsGeneratedSuccess = (web: boolean, result: FontResult) => { - console.log('Generator success') - if (web) { - generateWebCSS(result) - } else { - console.log('Webfont generated successfully... updating constants and flow types') - fs.writeFileSync(path.join(paths.fonts, 'kb.ttf'), result.ttf) - setFontMetrics() - updateIconConstants() - .then(() => {}) - .catch(() => {}) - } -} - -const generateWebCSS = (result: FontResult) => { - const svgFilenames = getSvgNames(false /* print skipped */) - const rules = svgFilenames.reduce<{[key: string]: number}>((map, {counter, name}) => { - map[`kb-iconfont-${name}`] = computeCounter(counter) - return map - }, {}) - - const typeToFormat = { - ttf: 'truetype', - woff: 'woff', - svg: 'svg', - } - - // hash and write - const types = (['ttf', 'woff', 'svg'] as const).map(type => { - const hash = crypto.createHash('md5') - hash.update(result[type]) - try { - fs.writeFileSync(path.join(paths.webFonts, `kb.${type}`), result[type]) - } catch (e) { - console.error(e) - } - return {type, hash: hash.digest('hex'), format: typeToFormat[type]} - }) - const urls = types - .map(type => `url('/fonts/kb.${type.type}?${type.hash}') format('${type.format}')`) - .join(',\n') - - const css = ` -/* - This file is how we serve our custom Coinbase, etc., fonts on the website - - ALSO see fonts.styl - SOURCE: - 1. Go to client and run \`yarn update-web-font\` - 2. Copy client/shared/fonts-for-web/fonts_custom.styl here - 3. Copy fonts to public/fonts -*/ - -@font-face { - font-family: "kb"; - src: ${urls}; - font-weight: normal; - font-style: normal; -} - -[class^="icon-kb-iconfont-"], [class*=" icon-kb-iconfont-"] { - /* use !important to prevent issues with browser extensions that change fonts */ - font-family: 'kb' !important; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - font-size: 16px; - - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -${Object.keys(rules) - .map( - name => `.icon-${name}:before { - content: "\\${rules[name]?.toString(16)}"; -}` - ) - .join('\n')}` - - try { - fs.writeFileSync(paths.webFontsCss, css, 'utf8') - } catch (e) { - console.error(e) - } -} - -const fontsGeneratedError = (error: unknown) => { - console.log( - `webfonts-generator failed to generate ttf iconfont file. Check that all svgs exist and the destination directory exits. ${String( - error - )}` - ) - process.exit(1) -} - type IconInfo = { extension?: string imagesDir?: string @@ -513,83 +296,6 @@ ${Object.keys(icons).reduce((res, name) => { } } -/* - * The final ttf output from webfonts-generator will not set the GASP or OS2/Metrics table in TTF metadata correctly. - * GASP will help with pixel alignment and antialiasing - * OS2/Metrics will set the ascent and descent values in metadata (rather than on the glyphs) - * To fix this, we need to force the following values using fontforge. - * - * --- - * OS/2 Table - * Documentation: https://docs.microsoft.com/en-us/typography/opentype/spec/os2ver1 - * --- - * WinAscent: ${fontHeight - descent + 2} - * WinDescent: ${descent * 2 + 20} - * TypoAscent: ${fontHeight - descent} - * TypoDescent: -${descent} - * HHeadAscent: ${fontHeight - descent + 2} - * HHeadDescent: -${descent * 2 + 20} - * - * --- - * GASP Table - * This is *super* important for anti-aliasing and grid snapping. - * If this is not set successfully then the icons will be visually blurry. - * Documentation: https://docs.microsoft.com/en-us/typography/opentype/spec/gasp#sample-gasp-table - * --- - * PixelSize: 65535 - * FlagValue: - * 0 means neither grid-fit nor anti-alias - * 1 means grid-fit but no anti-alias. - * 2 means no grid-fit but anti-alias. - * 3 means both grid-fit and anti-alias. - * - */ -const setFontMetrics = () => { - /* - * Arguments: - * $1: path to kb.ttf - * $2: ascent value - * $3: descent value - */ - const kbTtf = path.resolve(paths.fonts, 'kb.ttf') - // Nav icons need to be shifted more because the grid size is 24. - // Without shifting to -(64 + 21) the nav icons will be aligned on - // a half pixel which will cause blurriness. - const icon24 = getSvgNames(true) - .filter(({size}) => size === '24') - .map(({filePath}) => `'${filePath.replace('.svg', '')}'`) - const icon24First = icon24[0] - const icon24Last = icon24.at(-1) - let script = ` - Open('${kbTtf}'); - SetOS2Value('WinAscent', ${fontHeight - descent + 2}); - SetOS2Value('WinDescent', ${descent * 2 + 20}); - SetOS2Value('TypoAscent', ${fontHeight - descent}); - SetOS2Value('TypoLineGap', ${0}); - SetOS2Value('TypoDescent', ${-descent}); - SetOS2Value('HHeadAscent', ${fontHeight - descent + 2}); - SetOS2Value('HHeadDescent', ${-(descent * 2 + 20)}); - SetGasp(65535, 15); - SelectAll(); - Move(0, ${-descent}); - SelectNone(); - Select(${icon24First}, ${icon24Last}); - Move(0, ${-22}); - ScaleToEm(${fontHeight - descent}, ${descent}); - Generate('${kbTtf}'); - ` - script = script - .split('\n') - .map(x => x.trim()) - .join(' ') - const command = `fontforge -lang ff -c "${script}"` - try { - execSync(command, {encoding: 'utf8', env: process.env}) - } catch (e) { - console.error(e) - } -} - function unusedAssets() { const allFiles = fs.readdirSync(paths.iconPng) diff --git a/shared/fonts/.font-build-stamp b/shared/fonts/.font-build-stamp new file mode 100644 index 000000000000..3e4c2488ea5a --- /dev/null +++ b/shared/fonts/.font-build-stamp @@ -0,0 +1 @@ +1778166183.7354188 diff --git a/shared/fonts/.gitignore b/shared/fonts/.gitignore new file mode 100644 index 000000000000..9ab870da897d --- /dev/null +++ b/shared/fonts/.gitignore @@ -0,0 +1 @@ +generated/ diff --git a/shared/fonts/README.md b/shared/fonts/README.md new file mode 100644 index 000000000000..8b3a43265ce1 --- /dev/null +++ b/shared/fonts/README.md @@ -0,0 +1,154 @@ +# Keybase Fonts + +## Source Provenance + +### Keybase text fonts (`keybase-*.ttf`) +Mark Simonson Studio typeface renamed to `Keybase`, processed through Font Squirrel and +`ttfautohint`. **Only the processed TTFs exist in the repo — no `.glyphs`, `.ufo`, or +other source files are available.** The TTFs are the canonical inputs for the build pipeline. + +### Source Code Pro (`SourceCodePro-*.ttf`) +Adobe Source Code Pro, imported binaries. No modifications. + +### Icon font (`kb.ttf`) +Generated from `shared/images/iconfont/*.svg` by `shared/desktop/yarn-helper/font.mts`. +Codepoint assignment is driven by filename counters to stay stable across regenerations. + +--- + +## Directory Layout + +| Directory | Contents | +|-----------|----------| +| `shared/fonts/` | Canonical source TTFs and this README | +| `shared/fonts/ios/` | iOS-specific built outputs | +| `shared/fonts/electron/` | Electron-specific built outputs (may have patched metrics) | +| `shared/fonts/android/` | Android-specific built outputs (patched underline position) | +| `shared/fonts/generated/` | Intermediate staging from build tool; not platform assets | + +Platform-specific directories exist because different rendering engines interpret the same +font metrics differently. Each platform gets its own copy so patches can be applied per +platform without affecting others. + +--- + +## Build Commands + +Install the Python tooling dependencies once: + +```sh +pip3 install -r shared/tools/fonts/requirements.txt +``` + +Build all text fonts for iOS and Electron: + +```sh +yarn font:build-text +``` + +Build Android fonts (separate because they need different underline patches): + +```sh +yarn font:build-android +``` + +Build icon font (`kb.ttf`) for all platforms (iOS, Electron) from SVGs in `shared/images/iconfont/`: + +```sh +yarn font:build-icon +``` + +Build icon font for Android: + +```sh +yarn font:build-icon-android +``` + +Other commands: + +```sh +yarn font:inspect # dump OpenType table data for current fonts +yarn font:snapshot-metrics # regenerate shared/fonts/metrics.json from current outputs +yarn font:verify-text # verify generated fonts match metrics.json +yarn font:diff-metrics # compare metrics between two font directories +``` + +--- + +## Manifest (`manifest.json`) + +`manifest.json` is the single source of truth for the build. For each text font it declares: + +- `source` — canonical input TTF path +- `patches` — metric patches applied to all platforms (OS/2 table) +- `androidPatches` — additional patches for Android only (post table underlinePosition) +- `electronPatches` — additional patches for Electron only (see below) +- `outputs` — per-platform destination paths + +Edit the manifest when changing metrics or adding fonts, then run `yarn font:build-text`. + +--- + +## Platform Metric Quirks + +### Strikethrough position + +**Chromium/macOS (Electron):** +Chromium on macOS computes CSS `text-decoration: line-through` position from +`hhea.ascender` via Core Text. It does **not** use `OS/2.yStrikeoutPosition` or +`OS/2.sxHeight` — changing those fields has zero effect on strikethrough position in +Electron. + +To move the strikethrough down, reduce `hhea.ascender`. The current value for bold weights +in Electron is 1750 (down from the default 2210), which places the strike at the optical +center of the lowercase `a`. + +**iOS/Android:** +Core Text on iOS measures actual glyph bounding boxes, so `hhea.ascender` changes do not +affect strikethrough position there. iOS and Android use the unpatched fonts (hhea.ascender +stays at 2210). + +This is why bold and bold-italic have `electronPatches: {"hhea": {"ascender": 1750}}` in +the manifest — they need a lower value for Electron without breaking iOS. + +### OS/2 metrics (`sxHeight`, `sCapHeight`, `yStrikeoutPosition`) + +The original Font Squirrel processing halved `sxHeight` and `sCapHeight`. The manifest +patches correct these to the actual glyph measurements. `yStrikeoutPosition` is set to +approximately x-height / 2 per weight. + +These values have no effect on strikethrough rendering in Chromium on macOS (see above), +but they are correct for platforms that do use them and for font inspection tools. + +### Android underline position + +Android uses `post.underlinePosition` for underline placement. Each font has an +`androidPatches` entry in the manifest to set the correct per-weight value. + +--- + +## Metric Reference (keybase-medium baseline) + +| Metric | Value | Notes | +|--------|-------|-------| +| `sTypoAscender` | 1618 | | +| `sTypoDescender` | −430 | | +| `sTypoLineGap` | 0 | | +| `usWinAscent` | 2210 | | +| `usWinDescent` | 736 | | +| `hhea.ascender` | 2210 | Electron bold uses 1750 | +| `hhea.descender` | −736 | | +| `OS/2.sxHeight` | 989 | Corrected from Font Squirrel 495 | +| `OS/2.sCapHeight` | 1366 | Corrected from Font Squirrel 683 | +| `yStrikeoutPosition` | 460 | ≈ sxHeight / 2 | +| `post.underlinePosition` | −154 | | +| `post.underlineThickness` | 102 | | + +`USE_TYPO_METRICS` (`OS/2.fsSelection` bit 7) is currently **not set**. Line spacing uses +`usWinAscent`/`usWinDescent` on Windows and `hhea` on macOS/iOS. + +--- + +## Open Questions + +- Should web font generation remain part of this repo, or is it obsolete? diff --git a/shared/fonts/SourceCodePro-Medium.ttf b/shared/fonts/SourceCodePro-Medium.ttf old mode 100755 new mode 100644 index 827830b1ceeb..596cf64da13a Binary files a/shared/fonts/SourceCodePro-Medium.ttf and b/shared/fonts/SourceCodePro-Medium.ttf differ diff --git a/shared/fonts/SourceCodePro-Semibold.ttf b/shared/fonts/SourceCodePro-Semibold.ttf old mode 100755 new mode 100644 index c9e3774b0f0e..71d1e50118d9 Binary files a/shared/fonts/SourceCodePro-Semibold.ttf and b/shared/fonts/SourceCodePro-Semibold.ttf differ diff --git a/shared/fonts/android/SourceCodePro-Medium.ttf b/shared/fonts/android/SourceCodePro-Medium.ttf old mode 100755 new mode 100644 index 827830b1ceeb..e6a6e7750e35 Binary files a/shared/fonts/android/SourceCodePro-Medium.ttf and b/shared/fonts/android/SourceCodePro-Medium.ttf differ diff --git a/shared/fonts/android/SourceCodePro-Semibold.ttf b/shared/fonts/android/SourceCodePro-Semibold.ttf old mode 100755 new mode 100644 index c9e3774b0f0e..971ae5d7e0ce Binary files a/shared/fonts/android/SourceCodePro-Semibold.ttf and b/shared/fonts/android/SourceCodePro-Semibold.ttf differ diff --git a/shared/fonts/android/fixes.py b/shared/fonts/android/fixes.py deleted file mode 100755 index c2231dbfe6aa..000000000000 --- a/shared/fonts/android/fixes.py +++ /dev/null @@ -1,8 +0,0 @@ -#/usr/bin/env python3 -import glob -import fontforge - -for filename in glob.glob('./keybase*.ttf'): - font = fontforge.open(filename) - font.upos += 200 - font.generate(font.path) diff --git a/shared/fonts/android/keybase-extrabold.ttf b/shared/fonts/android/keybase-extrabold.ttf old mode 100755 new mode 100644 index e81a213237e5..4bfae39c6564 Binary files a/shared/fonts/android/keybase-extrabold.ttf and b/shared/fonts/android/keybase-extrabold.ttf differ diff --git a/shared/fonts/android/keybase-medium.ttf b/shared/fonts/android/keybase-medium.ttf old mode 100755 new mode 100644 index 306db23ad409..3fcdac6b47cb Binary files a/shared/fonts/android/keybase-medium.ttf and b/shared/fonts/android/keybase-medium.ttf differ diff --git a/shared/fonts/android/keybase-medium_italic.ttf b/shared/fonts/android/keybase-medium_italic.ttf old mode 100755 new mode 100644 index 1d6fdd48995d..d0eeca6366f8 Binary files a/shared/fonts/android/keybase-medium_italic.ttf and b/shared/fonts/android/keybase-medium_italic.ttf differ diff --git a/shared/fonts/android/keybase-semibold.ttf b/shared/fonts/android/keybase-semibold.ttf old mode 100755 new mode 100644 index c4e95bec55aa..55a8b6af3e3e Binary files a/shared/fonts/android/keybase-semibold.ttf and b/shared/fonts/android/keybase-semibold.ttf differ diff --git a/shared/fonts/android/keybase-semibold_italic.ttf b/shared/fonts/android/keybase-semibold_italic.ttf old mode 100755 new mode 100644 index 44b44be6bc24..a6ffd2f8b528 Binary files a/shared/fonts/android/keybase-semibold_italic.ttf and b/shared/fonts/android/keybase-semibold_italic.ttf differ diff --git a/shared/fonts/android/keybase_bold.ttf b/shared/fonts/android/keybase_bold.ttf old mode 100755 new mode 100644 index 8b10b5154981..81bf6e5b8cbb Binary files a/shared/fonts/android/keybase_bold.ttf and b/shared/fonts/android/keybase_bold.ttf differ diff --git a/shared/fonts/android/keybase_bold_italic.ttf b/shared/fonts/android/keybase_bold_italic.ttf old mode 100755 new mode 100644 index 2bb150794221..f8039eea867b Binary files a/shared/fonts/android/keybase_bold_italic.ttf and b/shared/fonts/android/keybase_bold_italic.ttf differ diff --git a/shared/fonts/electron/SourceCodePro-Medium.ttf b/shared/fonts/electron/SourceCodePro-Medium.ttf new file mode 100644 index 000000000000..46f77030809c Binary files /dev/null and b/shared/fonts/electron/SourceCodePro-Medium.ttf differ diff --git a/shared/fonts/electron/SourceCodePro-Semibold.ttf b/shared/fonts/electron/SourceCodePro-Semibold.ttf new file mode 100644 index 000000000000..5ca326048022 Binary files /dev/null and b/shared/fonts/electron/SourceCodePro-Semibold.ttf differ diff --git a/shared/fonts/electron/kb.ttf b/shared/fonts/electron/kb.ttf new file mode 100644 index 000000000000..289bf500a26d Binary files /dev/null and b/shared/fonts/electron/kb.ttf differ diff --git a/shared/fonts/electron/keybase-bold-italic.ttf b/shared/fonts/electron/keybase-bold-italic.ttf new file mode 100644 index 000000000000..80b9e364d122 Binary files /dev/null and b/shared/fonts/electron/keybase-bold-italic.ttf differ diff --git a/shared/fonts/electron/keybase-bold.ttf b/shared/fonts/electron/keybase-bold.ttf new file mode 100644 index 000000000000..899e3b8d3065 Binary files /dev/null and b/shared/fonts/electron/keybase-bold.ttf differ diff --git a/shared/fonts/electron/keybase-extrabold.ttf b/shared/fonts/electron/keybase-extrabold.ttf new file mode 100644 index 000000000000..ab790a623d73 Binary files /dev/null and b/shared/fonts/electron/keybase-extrabold.ttf differ diff --git a/shared/fonts/electron/keybase-medium-italic.ttf b/shared/fonts/electron/keybase-medium-italic.ttf new file mode 100644 index 000000000000..a60f644d8f27 Binary files /dev/null and b/shared/fonts/electron/keybase-medium-italic.ttf differ diff --git a/shared/fonts/electron/keybase-medium.ttf b/shared/fonts/electron/keybase-medium.ttf new file mode 100644 index 000000000000..dc122f3acbe8 Binary files /dev/null and b/shared/fonts/electron/keybase-medium.ttf differ diff --git a/shared/fonts/electron/keybase-semibold-italic.ttf b/shared/fonts/electron/keybase-semibold-italic.ttf new file mode 100644 index 000000000000..f5a75b48d273 Binary files /dev/null and b/shared/fonts/electron/keybase-semibold-italic.ttf differ diff --git a/shared/fonts/electron/keybase-semibold.ttf b/shared/fonts/electron/keybase-semibold.ttf new file mode 100644 index 000000000000..cac76f5c249e Binary files /dev/null and b/shared/fonts/electron/keybase-semibold.ttf differ diff --git a/shared/fonts/ios/SourceCodePro-Medium.ttf b/shared/fonts/ios/SourceCodePro-Medium.ttf new file mode 100644 index 000000000000..46f77030809c Binary files /dev/null and b/shared/fonts/ios/SourceCodePro-Medium.ttf differ diff --git a/shared/fonts/ios/SourceCodePro-Semibold.ttf b/shared/fonts/ios/SourceCodePro-Semibold.ttf new file mode 100644 index 000000000000..5ca326048022 Binary files /dev/null and b/shared/fonts/ios/SourceCodePro-Semibold.ttf differ diff --git a/shared/fonts/ios/kb.ttf b/shared/fonts/ios/kb.ttf new file mode 100644 index 000000000000..289bf500a26d Binary files /dev/null and b/shared/fonts/ios/kb.ttf differ diff --git a/shared/fonts/ios/keybase-bold-italic.ttf b/shared/fonts/ios/keybase-bold-italic.ttf new file mode 100644 index 000000000000..3a901645c332 Binary files /dev/null and b/shared/fonts/ios/keybase-bold-italic.ttf differ diff --git a/shared/fonts/ios/keybase-bold.ttf b/shared/fonts/ios/keybase-bold.ttf new file mode 100644 index 000000000000..3333d0576789 Binary files /dev/null and b/shared/fonts/ios/keybase-bold.ttf differ diff --git a/shared/fonts/ios/keybase-extrabold.ttf b/shared/fonts/ios/keybase-extrabold.ttf new file mode 100644 index 000000000000..ab790a623d73 Binary files /dev/null and b/shared/fonts/ios/keybase-extrabold.ttf differ diff --git a/shared/fonts/ios/keybase-medium-italic.ttf b/shared/fonts/ios/keybase-medium-italic.ttf new file mode 100644 index 000000000000..a60f644d8f27 Binary files /dev/null and b/shared/fonts/ios/keybase-medium-italic.ttf differ diff --git a/shared/fonts/ios/keybase-medium.ttf b/shared/fonts/ios/keybase-medium.ttf new file mode 100644 index 000000000000..dc122f3acbe8 Binary files /dev/null and b/shared/fonts/ios/keybase-medium.ttf differ diff --git a/shared/fonts/ios/keybase-semibold-italic.ttf b/shared/fonts/ios/keybase-semibold-italic.ttf new file mode 100644 index 000000000000..f5a75b48d273 Binary files /dev/null and b/shared/fonts/ios/keybase-semibold-italic.ttf differ diff --git a/shared/fonts/ios/keybase-semibold.ttf b/shared/fonts/ios/keybase-semibold.ttf new file mode 100644 index 000000000000..cac76f5c249e Binary files /dev/null and b/shared/fonts/ios/keybase-semibold.ttf differ diff --git a/shared/fonts/kb.ttf b/shared/fonts/kb.ttf index 369eba8a0940..289bf500a26d 100644 Binary files a/shared/fonts/kb.ttf and b/shared/fonts/kb.ttf differ diff --git a/shared/fonts/keybase-bold-italic.ttf b/shared/fonts/keybase-bold-italic.ttf old mode 100755 new mode 100644 index a3ca04d81bb3..e737f4b6e82e Binary files a/shared/fonts/keybase-bold-italic.ttf and b/shared/fonts/keybase-bold-italic.ttf differ diff --git a/shared/fonts/keybase-bold.ttf b/shared/fonts/keybase-bold.ttf old mode 100755 new mode 100644 index 1c3dbb1719d0..d8ac1d5b8576 Binary files a/shared/fonts/keybase-bold.ttf and b/shared/fonts/keybase-bold.ttf differ diff --git a/shared/fonts/keybase-extrabold.ttf b/shared/fonts/keybase-extrabold.ttf old mode 100755 new mode 100644 index 3ebbe18fd824..8abf60307ed4 Binary files a/shared/fonts/keybase-extrabold.ttf and b/shared/fonts/keybase-extrabold.ttf differ diff --git a/shared/fonts/keybase-medium-italic.ttf b/shared/fonts/keybase-medium-italic.ttf old mode 100755 new mode 100644 index 11dd3c3940e7..e368046bab10 Binary files a/shared/fonts/keybase-medium-italic.ttf and b/shared/fonts/keybase-medium-italic.ttf differ diff --git a/shared/fonts/keybase-medium.ttf b/shared/fonts/keybase-medium.ttf old mode 100755 new mode 100644 index 05e8973bd789..6083bf40d9d3 Binary files a/shared/fonts/keybase-medium.ttf and b/shared/fonts/keybase-medium.ttf differ diff --git a/shared/fonts/keybase-semibold-italic.ttf b/shared/fonts/keybase-semibold-italic.ttf old mode 100755 new mode 100644 index ffcc25454544..42f202c65ab8 Binary files a/shared/fonts/keybase-semibold-italic.ttf and b/shared/fonts/keybase-semibold-italic.ttf differ diff --git a/shared/fonts/keybase-semibold.ttf b/shared/fonts/keybase-semibold.ttf old mode 100755 new mode 100644 index 07d0a06f8668..5ef1d9958e86 Binary files a/shared/fonts/keybase-semibold.ttf and b/shared/fonts/keybase-semibold.ttf differ diff --git a/shared/fonts/manifest.json b/shared/fonts/manifest.json new file mode 100644 index 000000000000..349f0f1ca89b --- /dev/null +++ b/shared/fonts/manifest.json @@ -0,0 +1,156 @@ +{ + "_comment": "Declarative source-to-output mapping. 'source' is the canonical input (currently the checked-in TTF). 'outputs' lists per-platform asset destinations relative to repo root. iOS uses shared/fonts/ios/, Electron uses shared/fonts/electron/ (may have electronPatches), Android uses shared/fonts/android/ with per-font underlinePosition patches.", + "buildConfig": { + "_comment": "outputDir is relative to repo root. patches applies to all text fonts. androidOutputDir holds Android-specific generated fonts until a platform-agnostic underline metric is decided (Phase 4). Set USE_TYPO_METRICS to true once metrics policy is finalized.", + "outputDir": "shared/fonts/generated", + "androidOutputDir": "shared/fonts/generated/android", + "patches": { + "OS/2": { + "USE_TYPO_METRICS": false + } + } + }, + "textFonts": [ + { + "id": "keybase-medium", + "family": "Keybase", + "weight": 500, + "italic": false, + "source": "shared/fonts/keybase-medium.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 460}}, + "androidPatches": {"post": {"underlinePosition": -292}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-medium.ttf", + "android": "shared/fonts/android/keybase-medium.ttf", + "electron": "shared/fonts/electron/keybase-medium.ttf" + } + }, + { + "id": "keybase-medium-italic", + "family": "Keybase", + "weight": 500, + "italic": true, + "source": "shared/fonts/keybase-medium-italic.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 460}}, + "androidPatches": {"post": {"underlinePosition": -292}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-medium-italic.ttf", + "android": "shared/fonts/android/keybase-medium_italic.ttf", + "electron": "shared/fonts/electron/keybase-medium-italic.ttf" + } + }, + { + "id": "keybase-semibold", + "family": "Keybase", + "weight": 600, + "italic": false, + "source": "shared/fonts/keybase-semibold.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 471}}, + "androidPatches": {"post": {"underlinePosition": -352}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-semibold.ttf", + "android": "shared/fonts/android/keybase-semibold.ttf", + "electron": "shared/fonts/electron/keybase-semibold.ttf" + } + }, + { + "id": "keybase-semibold-italic", + "family": "Keybase", + "weight": 600, + "italic": true, + "source": "shared/fonts/keybase-semibold-italic.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 471}}, + "androidPatches": {"post": {"underlinePosition": -352}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-semibold-italic.ttf", + "android": "shared/fonts/android/keybase-semibold_italic.ttf", + "electron": "shared/fonts/electron/keybase-semibold-italic.ttf" + } + }, + { + "id": "keybase-bold", + "family": "Keybase", + "weight": 700, + "italic": false, + "source": "shared/fonts/keybase-bold.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 485}}, + "androidPatches": {"post": {"underlinePosition": -446}}, + "electronPatches": {"hhea": {"ascender": 2180}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-bold.ttf", + "android": "shared/fonts/android/keybase_bold.ttf", + "electron": "shared/fonts/electron/keybase-bold.ttf" + } + }, + { + "id": "keybase-bold-italic", + "family": "Keybase", + "weight": 700, + "italic": true, + "source": "shared/fonts/keybase-bold-italic.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 485}}, + "androidPatches": {"post": {"underlinePosition": -446}}, + "electronPatches": {"hhea": {"ascender": 2180}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-bold-italic.ttf", + "android": "shared/fonts/android/keybase_bold_italic.ttf", + "electron": "shared/fonts/electron/keybase-bold-italic.ttf" + } + }, + { + "id": "keybase-extrabold", + "family": "Keybase", + "weight": 800, + "italic": false, + "source": "shared/fonts/keybase-extrabold.ttf", + "provenance": "Mark Simonson Studio font renamed to Keybase, processed via Font Squirrel and ttfautohint", + "patches": {"OS/2": {"sxHeight": 989, "sCapHeight": 1366, "yStrikeoutPosition": 501}}, + "androidPatches": {"post": {"underlinePosition": -542}}, + "outputs": { + "ios": "shared/fonts/ios/keybase-extrabold.ttf", + "android": "shared/fonts/android/keybase-extrabold.ttf", + "electron": "shared/fonts/electron/keybase-extrabold.ttf" + } + }, + { + "id": "sourcecodepro-medium", + "family": "Source Code Pro", + "weight": 500, + "italic": false, + "source": "shared/fonts/SourceCodePro-Medium.ttf", + "provenance": "Adobe Source Code Pro, imported binary", + "outputs": { + "ios": "shared/fonts/ios/SourceCodePro-Medium.ttf", + "android": "shared/fonts/android/SourceCodePro-Medium.ttf", + "electron": "shared/fonts/electron/SourceCodePro-Medium.ttf" + } + }, + { + "id": "sourcecodepro-semibold", + "family": "Source Code Pro", + "weight": 600, + "italic": false, + "source": "shared/fonts/SourceCodePro-Semibold.ttf", + "provenance": "Adobe Source Code Pro, imported binary", + "outputs": { + "ios": "shared/fonts/ios/SourceCodePro-Semibold.ttf", + "android": "shared/fonts/android/SourceCodePro-Semibold.ttf", + "electron": "shared/fonts/electron/SourceCodePro-Semibold.ttf" + } + } + ], + "iconFont": { + "id": "kb", + "source": "shared/images/iconfont/*.svg", + "output": "shared/fonts/kb.ttf", + "outputs": ["shared/fonts/kb.ttf", "shared/fonts/ios/kb.ttf", "shared/fonts/electron/kb.ttf"], + "androidOutput": "shared/fonts/android/kb.ttf", + "generator": "shared/tools/fonts/font_tool.py" + } +} diff --git a/shared/fonts/metrics.json b/shared/fonts/metrics.json new file mode 100644 index 000000000000..abceec7c7964 --- /dev/null +++ b/shared/fonts/metrics.json @@ -0,0 +1,275 @@ +{ + "_comment": "Generated snapshot \u2014 do not edit by hand. Run: yarn font:snapshot-metrics", + "fonts": { + "keybase-medium": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -736, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 736, + "yStrikeoutPosition": 560, + "yStrikeoutSize": 131, + "usWeightClass": 500, + "fsSelection": 64, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -766, + "underlineThickness": 131, + "italicAngle": 0.0 + } + }, + "keybase-medium-italic": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -736, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 736, + "yStrikeoutPosition": 560, + "yStrikeoutSize": 131, + "usWeightClass": 500, + "fsSelection": 1, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -766, + "underlineThickness": 131, + "italicAngle": -12.5 + } + }, + "keybase-semibold": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -767, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 767, + "yStrikeoutPosition": 571, + "yStrikeoutSize": 152, + "usWeightClass": 600, + "fsSelection": 64, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -826, + "underlineThickness": 151, + "italicAngle": 0.0 + } + }, + "keybase-semibold-italic": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -767, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 767, + "yStrikeoutPosition": 571, + "yStrikeoutSize": 152, + "usWeightClass": 600, + "fsSelection": 1, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -826, + "underlineThickness": 151, + "italicAngle": -12.5 + } + }, + "keybase-bold": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -794, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 794, + "yStrikeoutPosition": 585, + "yStrikeoutSize": 180, + "usWeightClass": 700, + "fsSelection": 32, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -916, + "underlineThickness": 180, + "italicAngle": 0.0 + } + }, + "keybase-bold-italic": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -794, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 794, + "yStrikeoutPosition": 585, + "yStrikeoutSize": 180, + "usWeightClass": 700, + "fsSelection": 33, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -916, + "underlineThickness": 180, + "italicAngle": -12.5 + } + }, + "keybase-extrabold": { + "unitsPerEm": 2048, + "hhea": { + "ascender": 2210, + "descender": -824, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 1618, + "sTypoDescender": -430, + "sTypoLineGap": 0, + "usWinAscent": 2210, + "usWinDescent": 824, + "yStrikeoutPosition": 601, + "yStrikeoutSize": 213, + "usWeightClass": 800, + "fsSelection": 64, + "USE_TYPO_METRICS": false, + "sxHeight": 989, + "sCapHeight": 1366 + }, + "post": { + "underlinePosition": -1012, + "underlineThickness": 212, + "italicAngle": 0.0 + } + }, + "sourcecodepro-medium": { + "unitsPerEm": 1000, + "hhea": { + "ascender": 984, + "descender": -273, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 750, + "sTypoDescender": -250, + "sTypoLineGap": 0, + "usWinAscent": 984, + "usWinDescent": 273, + "yStrikeoutPosition": 288, + "yStrikeoutSize": 50, + "usWeightClass": 500, + "fsSelection": 0, + "USE_TYPO_METRICS": false, + "sxHeight": 480, + "sCapHeight": 660 + }, + "post": { + "underlinePosition": -75, + "underlineThickness": 50, + "italicAngle": 0.0 + } + }, + "sourcecodepro-semibold": { + "unitsPerEm": 1000, + "hhea": { + "ascender": 984, + "descender": -273, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 750, + "sTypoDescender": -250, + "sTypoLineGap": 0, + "usWinAscent": 984, + "usWinDescent": 273, + "yStrikeoutPosition": 288, + "yStrikeoutSize": 50, + "usWeightClass": 600, + "fsSelection": 0, + "USE_TYPO_METRICS": false, + "sxHeight": 480, + "sCapHeight": 660 + }, + "post": { + "underlinePosition": -75, + "underlineThickness": 50, + "italicAngle": 0.0 + } + }, + "kb": { + "unitsPerEm": 1024, + "hhea": { + "ascender": 962, + "descender": -148, + "lineGap": 0 + }, + "OS/2": { + "sTypoAscender": 960, + "sTypoDescender": -64, + "sTypoLineGap": 0, + "usWinAscent": 962, + "usWinDescent": 148, + "yStrikeoutPosition": 264, + "yStrikeoutSize": 50, + "usWeightClass": 400, + "fsSelection": 64, + "USE_TYPO_METRICS": false, + "sxHeight": null, + "sCapHeight": null + }, + "post": { + "underlinePosition": 10, + "underlineThickness": 0, + "italicAngle": 0.0 + } + } + } +} \ No newline at end of file diff --git a/shared/ios/Keybase.xcodeproj/project.pbxproj b/shared/ios/Keybase.xcodeproj/project.pbxproj index 741c66de0fb5..93ea6c841683 100644 --- a/shared/ios/Keybase.xcodeproj/project.pbxproj +++ b/shared/ios/Keybase.xcodeproj/project.pbxproj @@ -87,16 +87,16 @@ 279FB6F321403A49005E4864 /* MobileCoreServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MobileCoreServices.framework; path = System/Library/Frameworks/MobileCoreServices.framework; sourceTree = SDKROOT; }; 279FB6F521403B8B005E4864 /* KeybaseShare.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KeybaseShare.entitlements; sourceTree = ""; }; 3791A8A220C71FE6001C2E86 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; - 37E0D87E22A5C7FA008CA4ED /* keybase-semibold-italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-semibold-italic.ttf"; path = "../fonts/keybase-semibold-italic.ttf"; sourceTree = ""; }; - 37E0D87F22A5C7FA008CA4ED /* keybase-extrabold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-extrabold.ttf"; path = "../fonts/keybase-extrabold.ttf"; sourceTree = ""; }; - 37E0D88022A5C7FA008CA4ED /* keybase-bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-bold.ttf"; path = "../fonts/keybase-bold.ttf"; sourceTree = ""; }; - 37E0D88122A5C7FA008CA4ED /* keybase-medium-italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-medium-italic.ttf"; path = "../fonts/keybase-medium-italic.ttf"; sourceTree = ""; }; - 37E0D88222A5C7FA008CA4ED /* keybase-medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-medium.ttf"; path = "../fonts/keybase-medium.ttf"; sourceTree = ""; }; - 37E0D88322A5C7FA008CA4ED /* keybase-semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-semibold.ttf"; path = "../fonts/keybase-semibold.ttf"; sourceTree = ""; }; - 37E0D88422A5C7FA008CA4ED /* keybase-bold-italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-bold-italic.ttf"; path = "../fonts/keybase-bold-italic.ttf"; sourceTree = ""; }; - 37E0D88522A5C7FA008CA4ED /* SourceCodePro-Semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "SourceCodePro-Semibold.ttf"; path = "../fonts/SourceCodePro-Semibold.ttf"; sourceTree = ""; }; - 37E0D88622A5C7FB008CA4ED /* kb.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = kb.ttf; path = ../fonts/kb.ttf; sourceTree = ""; }; - 37E0D88722A5C7FB008CA4ED /* SourceCodePro-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "SourceCodePro-Medium.ttf"; path = "../fonts/SourceCodePro-Medium.ttf"; sourceTree = ""; }; + 37E0D87E22A5C7FA008CA4ED /* keybase-semibold-italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-semibold-italic.ttf"; path = "../fonts/ios/keybase-semibold-italic.ttf"; sourceTree = ""; }; + 37E0D87F22A5C7FA008CA4ED /* keybase-extrabold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-extrabold.ttf"; path = "../fonts/ios/keybase-extrabold.ttf"; sourceTree = ""; }; + 37E0D88022A5C7FA008CA4ED /* keybase-bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-bold.ttf"; path = "../fonts/ios/keybase-bold.ttf"; sourceTree = ""; }; + 37E0D88122A5C7FA008CA4ED /* keybase-medium-italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-medium-italic.ttf"; path = "../fonts/ios/keybase-medium-italic.ttf"; sourceTree = ""; }; + 37E0D88222A5C7FA008CA4ED /* keybase-medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-medium.ttf"; path = "../fonts/ios/keybase-medium.ttf"; sourceTree = ""; }; + 37E0D88322A5C7FA008CA4ED /* keybase-semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-semibold.ttf"; path = "../fonts/ios/keybase-semibold.ttf"; sourceTree = ""; }; + 37E0D88422A5C7FA008CA4ED /* keybase-bold-italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "keybase-bold-italic.ttf"; path = "../fonts/ios/keybase-bold-italic.ttf"; sourceTree = ""; }; + 37E0D88522A5C7FA008CA4ED /* SourceCodePro-Semibold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "SourceCodePro-Semibold.ttf"; path = "../fonts/ios/SourceCodePro-Semibold.ttf"; sourceTree = ""; }; + 37E0D88622A5C7FB008CA4ED /* kb.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = kb.ttf; path = "../fonts/ios/kb.ttf"; sourceTree = ""; }; + 37E0D88722A5C7FB008CA4ED /* SourceCodePro-Medium.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "SourceCodePro-Medium.ttf"; path = "../fonts/ios/SourceCodePro-Medium.ttf"; sourceTree = ""; }; 37E0D89222A5C83B008CA4ED /* keybasemessage.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; name = keybasemessage.wav; path = ../sounds/keybasemessage.wav; sourceTree = ""; }; 60B93983273F902C00A226FF /* keybase.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = keybase.xcframework; sourceTree = ""; }; 698E0E782682CDB9F02EDD13 /* Pods-KeybaseShare.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-KeybaseShare.release.xcconfig"; path = "Pods/Target Support Files/Pods-KeybaseShare/Pods-KeybaseShare.release.xcconfig"; sourceTree = ""; }; diff --git a/shared/package.json b/shared/package.json index 5e9665aad091..7818b3412e66 100644 --- a/shared/package.json +++ b/shared/package.json @@ -17,8 +17,14 @@ "prettier-debug-check": "git ls-files | grep \"\\.\\(js\\|ts\\|tsx\\|mts\\)\\$\" | xargs ./node_modules/.bin/prettier --debug-check", "prettier-write-all": "yarn run _helper prettier-write-all", "update-icon-constants": "yarn run _helper update-icon-constants", - "update-icon-font": "yarn run _helper update-icon-font", - "update-web-font": "yarn run _helper update-web-font", + "font:inspect": "python3 tools/fonts/font_tool.py inspect", + "font:snapshot-metrics": "python3 tools/fonts/font_tool.py snapshot-metrics --manifest fonts/manifest.json --output fonts/metrics.json", + "font:build-text": "python3 tools/fonts/font_tool.py build-text --manifest fonts/manifest.json", + "font:build-android": "python3 tools/fonts/font_tool.py build-text --manifest fonts/manifest.json --platform android", + "font:build-icon": "python3 tools/fonts/font_tool.py build-icon --manifest fonts/manifest.json", + "font:build-icon-android": "python3 tools/fonts/font_tool.py build-icon --manifest fonts/manifest.json --platform android", + "font:verify-text": "python3 tools/fonts/font_tool.py verify-text --manifest fonts/manifest.json --metrics fonts/metrics.json", + "font:diff-metrics": "python3 tools/fonts/font_tool.py diff-metrics", "update-protocol": "cd ../protocol && make clean && make", "unused-assets": "yarn run _helper unused-assets", "build-treeshake": "yarn run _helper build-treeshake", diff --git a/shared/router-v2/router.native.tsx b/shared/router-v2/router.native.tsx index a1b6a795bb87..3c6c1ba870b9 100644 --- a/shared/router-v2/router.native.tsx +++ b/shared/router-v2/router.native.tsx @@ -58,7 +58,6 @@ const tabStackOptions = ({ navigation: {canGoBack: () => boolean} }): NativeStackNavigationOptions => ({ ...Common.defaultNavigationOptions, - ...(Platform.OS === 'ios' ? {contentStyle: {backgroundColor: Kb.Styles.globalColors.whiteOrBlack}} : {}), // Use the native back button (liquid glass pill on iOS 26) for non-root screens; // omit headerLeft entirely on root screens so no empty glass circle appears. headerBackVisible: navigation.canGoBack(), diff --git a/shared/router-v2/screen-layout.native.tsx b/shared/router-v2/screen-layout.native.tsx index 6a7ed6f3fc11..ae0abbcd42d6 100644 --- a/shared/router-v2/screen-layout.native.tsx +++ b/shared/router-v2/screen-layout.native.tsx @@ -1,7 +1,8 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import {SafeAreaProvider, initialWindowMetrics} from 'react-native-safe-area-context' -import {isTablet, isIOS} from '@/constants/platform' +import {isTablet, isIOS, isAndroid} from '@/constants/platform' +import {SafeAreaView as RNScreensSafeAreaView} from 'react-native-screens/experimental' import type {GetOptions, GetOptionsParams} from '@/constants/types/router' const modalOffset = isIOS ? 40 : 0 @@ -13,6 +14,13 @@ type LayoutProps = { } const TabScreenWrapper = ({children}: {children: React.ReactNode}) => { + if (isAndroid) { + return ( + + {children} + + ) + } return ( {children} diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index ef8abbf85236..17b1023a1585 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -283,17 +283,13 @@ function Tab(props: TabProps) { 'tab-tooltip', 'tooltip-top-right' )} - relative={true} + relative={true} style={styles.tab} tooltip={`${label} (${Platforms.shortcutSymbol}${index + 1})`} > - + {tab === Tabs.fsTab && } diff --git a/shared/settings/icons.tsx b/shared/settings/icons.tsx new file mode 100644 index 000000000000..5e7633eade2b --- /dev/null +++ b/shared/settings/icons.tsx @@ -0,0 +1,83 @@ +// Dev-only icon browser. Gated by __DEV__ in nav and routes — never visible in production. +import * as Kb from '@/common-adapters' +import {iconMeta} from '@/common-adapters/icon.constants-gen.shared' +import type {IconType} from '@/common-adapters/icon.constants-gen.d' +import * as React from 'react' + +const iconfontTypes: ReadonlyArray = (Object.keys(iconMeta) as Array) + .filter(k => k.startsWith('iconfont-')) + .sort() + +const CELL_SIZE = 80 + +const IconCell = ({type}: {type: IconType}) => { + const name = type.replace(/^iconfont-/, '') + return ( + + + + {name} + + + ) +} + +const Icons = () => { + const [query, setQuery] = React.useState('') + const filtered = query + ? iconfontTypes.filter(t => t.includes(query.toLowerCase())) + : iconfontTypes + + return ( + + + + + {filtered.length} / {iconfontTypes.length} + + + + + {filtered.map(t => ( + + ))} + + + + ) +} + +const styles = Kb.Styles.styleSheetCreate(() => ({ + cell: { + height: CELL_SIZE, + padding: Kb.Styles.globalMargins.xtiny, + width: CELL_SIZE, + }, + cellLabel: { + color: Kb.Styles.globalColors.black_50, + marginTop: 2, + textAlign: 'center', + }, + count: { + color: Kb.Styles.globalColors.black_50, + marginLeft: Kb.Styles.globalMargins.small, + }, + grid: { + flexWrap: 'wrap', + padding: Kb.Styles.globalMargins.tiny, + }, + scroll: {flex: 1}, + searchRow: { + borderBottomColor: Kb.Styles.globalColors.black_10, + borderBottomWidth: 1, + padding: Kb.Styles.globalMargins.small, + }, +})) + +export default Icons diff --git a/shared/settings/root-phone.tsx b/shared/settings/root-phone.tsx index 47455937666a..65bbab56a2ba 100644 --- a/shared/settings/root-phone.tsx +++ b/shared/settings/root-phone.tsx @@ -180,6 +180,22 @@ function SettingsNav() { }, text: 'About', }, + ...(__DEV__ + ? [ + { + onClick: () => { + navigateAppend({name: Settings.settingsTypographyTab, params: {}}) + }, + text: 'Typography', + } as const, + { + onClick: () => { + navigateAppend({name: Settings.settingsIconsTab, params: {}}) + }, + text: 'Icons', + } as const, + ] + : []), { onClick: () => { navigateAppend({name: Settings.settingsLogOutTab, params: {}}) diff --git a/shared/settings/routes.tsx b/shared/settings/routes.tsx index ad736c146e96..e586a227e210 100644 --- a/shared/settings/routes.tsx +++ b/shared/settings/routes.tsx @@ -131,6 +131,18 @@ export const sharedNewRoutes = defineRouteMap({ }, keybaseLinkError: {screen: React.lazy(async () => import('../deeplinks/error'))}, makeIcons: {screen: React.lazy(async () => import('./make-icons.page'))}, + ...(__DEV__ + ? { + [Settings.settingsTypographyTab]: { + getOptions: {title: 'Typography'}, + screen: React.lazy(async () => import('./typography')), + }, + [Settings.settingsIconsTab]: { + getOptions: {title: 'Icons'}, + screen: React.lazy(async () => import('./icons')), + }, + } + : {}), }) export const settingsDesktopTabRoutes = defineRouteMap({ @@ -144,6 +156,12 @@ export const settingsDesktopTabRoutes = defineRouteMap({ [Settings.settingsDisplayTab]: sharedNewRoutes[Settings.settingsDisplayTab], [Settings.settingsFeedbackTab]: sharedNewRoutes[Settings.settingsFeedbackTab], [Settings.settingsFsTab]: sharedNewRoutes[Settings.settingsFsTab], + ...(__DEV__ + ? { + [Settings.settingsTypographyTab]: sharedNewRoutes[Settings.settingsTypographyTab], + [Settings.settingsIconsTab]: sharedNewRoutes[Settings.settingsIconsTab], + } + : {}), [Settings.settingsGitTab]: sharedNewRoutes[Settings.settingsGitTab], [Settings.settingsNotificationsTab]: sharedNewRoutes[Settings.settingsNotificationsTab], [Settings.settingsScreenprotectorTab]: sharedNewRoutes[Settings.settingsScreenprotectorTab], diff --git a/shared/settings/sub-nav/left-nav.tsx b/shared/settings/sub-nav/left-nav.tsx index 41f6c99cb6a1..ce1335f4437a 100644 --- a/shared/settings/sub-nav/left-nav.tsx +++ b/shared/settings/sub-nav/left-nav.tsx @@ -124,6 +124,22 @@ const LeftNav = (props: Props) => { selected={props.selected === Settings.settingsWalletsTab} onClick={props.onClick} /> + {__DEV__ && ( + + )} + {__DEV__ && ( + + )} = [ + {fs: 12, lh: 16, type: 'BodyTiny'}, + {fs: 13, lh: 17, type: 'BodySmall'}, + {fs: 14, lh: 18, type: 'Body'}, + {fs: 15, lh: 19, type: 'BodyBig'}, + {fs: 18, lh: 22, type: 'Header'}, + {fs: 24, lh: 28, type: 'HeaderBig'}, +] + +// ───────────────────────────────────────────── +// Zone overlay — shows baseline / cap / x zones +// ───────────────────────────────────────────── +const ZoneRow = ({type, fs, lh}: {type: TextType; fs: number; lh: number}) => { + const z = zonePos(fs, lh) + const clamp = (v: number) => Math.max(0, Math.min(lh, v)) + // coloured bands between zone boundaries + const bands = [ + {color: '#dbeafe', from: 0, to: clamp(z.cap)}, // ascender zone (blue) + {color: '#dcfce7', from: clamp(z.cap), to: clamp(z.xh)}, // cap zone (green) + {color: '#fef9c3', from: clamp(z.xh), to: clamp(z.baseline)}, // x zone (yellow) + {color: '#fee2e2', from: clamp(z.baseline), to: lh}, // descender zone (red) + ] + return ( + + {type} {fs}/{lh} + + {/* coloured zone bands */} + {bands.map((b, i) => ( + + ))} + {/* reference lines at cap / x / baseline */} + {([ + {color: '#166534', label: 'cap', top: z.cap}, + {color: '#854d0e', label: 'x', top: z.xh}, + {color: '#7f1d1d', label: 'base', top: z.baseline}, + ] as const).map(l => ( + + ))} + Hamburgefontsiv gjpqy 0123456789 + + + ) +} + +const ZoneSection = () => ( + + Metric zone overlay + + Blue=ascender Green=cap zone Yellow=x zone Red=descender{'\n'} + Lines: dark green=cap-top brown=x-height dark red=baseline + + {TYPE_METRICS.map(m => ( + + ))} + +) + +// ───────────────────────────────────────────── +// Strikethrough & underline position +// ───────────────────────────────────────────── +const DecorationSection = () => ( + + Strikethrough (yStrikeoutPosition) — should bisect caps optically + {TYPE_METRICS.map(({type}) => ( + + {type} + Hamburgefontsiv 0123456789 ÁÉÍÓÚ + + ))} + + Underline (underlinePosition) — should sit just below descenders + {TYPE_METRICS.map(({type}) => ( + + {type} + Hamburgefontsiv gjpqy 0123456789 + + ))} + + Both together + {TYPE_METRICS.map(({type}) => ( + + {type} + Hamburgefontsiv 0123456789 + + ))} + +) + +// ───────────────────────────────────────────── +// Inline icon + text (sxHeight / vertical-align) +// ───────────────────────────────────────────── +const iconSizePairs = [ + {iconSize: 'Tiny', textType: 'BodyTiny'}, + {iconSize: 'Small', textType: 'BodySmall'}, + {iconSize: 'Default', textType: 'Body'}, + {iconSize: 'Big', textType: 'BodyBig'}, +] as const + +const InlineIconSection = () => ( + + Inline icon + text (sxHeight → vertical-align: middle) + Icons should sit at the optical mid-cap of adjacent text + {iconSizePairs.map(({iconSize, textType}) => ( + + {textType} + + Hamburgefontsiv + + gjpqy 0123456789 + + + ))} + + Mixed icon + bold + regular inline + {iconSizePairs.map(({iconSize, textType}) => ( + + {textType} + + Bold regular italic + + ))} + +) + +// ───────────────────────────────────────────── +// Fixed-height container centering (hhea.ascender) +// ───────────────────────────────────────────── +const containerHeights = [16, 20, 24, 28, 32, 40, 48] as const + +const CenteringSection = () => ( + + Fixed-height container centering (hhea.ascender) + Text must be visually centered in each box. Red line = exact center. + + {containerHeights.map(h => ( + + + Ag + {/* exact-center line */} + + + {h}px + + ))} + + Badge pills (orange) — same test + + {([1, 9, 42, 99, 999] as const).map(n => ( + + + {n} + + ))} + + +) + +// ───────────────────────────────────────────── +// Baseline alignment across mixed sizes +// ───────────────────────────────────────────── +const BaselineSection = () => ( + + Baseline alignment — mixed sizes on one line + All text should share a single baseline regardless of size + {/* On desktop these render as inline spans so baseline aligns naturally */} + {(['BodyTiny', 'BodySmall', 'Body', 'BodyBig', 'Header'] as const).map((_, i, arr) => ( + + {arr.slice(0, i + 2).map(t => ( + Hg + ))} + + ))} + + Weight mixing — bold / regular / semibold same line + {(['BodyTiny', 'BodySmall', 'Body', 'BodyBig'] as const).map(type => ( + + {type} + regular + bold + regular + italic + + ))} + +) + +// ───────────────────────────────────────────── +// Multi-line paragraph — line height / leading +// ───────────────────────────────────────────── +const PARA = 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. gjpqy ÁÉÍÓÚ ÅÄÖ 0123456789.' + +const LineHeightSection = () => ( + + Multi-line paragraph — line-height / leading (hhea.ascender) + Lines should be evenly spaced with no clipping or overlap + {TYPE_METRICS.map(({type}) => ( + + {type} + {PARA} + + ))} + +) + +// ───────────────────────────────────────────── +// Cap-height & x-height visual ruler +// ───────────────────────────────────────────── +const RulerSection = () => ( + + Cap-height & x-height uniformity (sCapHeight, sxHeight) + All caps must reach the same height; x-height glyphs (a e o x) must be consistent + {TYPE_METRICS.map(({type}) => ( + + {type} + ABCDEFGHIJKLMNOPQRSTUVWXYZ + + ))} + + {TYPE_METRICS.map(({type}) => ( + + {type} + abcdefghijklmnopqrstuvwxyz + + ))} + + Diacritics — should not clip (usWinAscent) + {TYPE_METRICS.map(({type}) => ( + + {type} + ÁÀÂÄÃÅÆÉÈÊËÍÌÎÏÓÒÔÖÕÚÙÛÜ ÅÄÖ áàâäéèêëíîóôöúùû + + ))} + +) + +// ───────────────────────────────────────────── +// Markdown — exercises all decoration + weight paths +// ───────────────────────────────────────────── +const MarkdownSection = () => ( + + Markdown rendering + {[ + '~~strikethrough~~ and **bold** and _italic_ together', + '**bold** `code` [link](https://keybase.io) normal', + '~~strike **bold strike** strike~~ normal', + '_italic **bold italic** italic_ normal', + ].map((md, i) => ( + {md} + ))} + +) + +// ───────────────────────────────────────────── +// Main sample rows (interactive, from original screen) +// ───────────────────────────────────────────── +const textTypes: ReadonlyArray = [ + 'BodyTiny', 'BodySmall', 'Body', 'BodyBig', 'Header', 'HeaderBig', +] + +const decorationOptions = ['none', 'underline', 'strikethrough', 'underline+strikethrough'] as const +type Decoration = (typeof decorationOptions)[number] + +const sampleStrings = [ + 'Hamburgefontsiv', + 'Hxpxgy', + '0123456789', + 'gjpqy ÁÉÍÓÚ ÅÄÖ', + 'The quick brown fox', +] + +type LayoutMetrics = { + ascender: number; descender: number; capHeight: number + xHeight: number; width: number; height: number; lineCount: number +} + +const SampleRow = ({textType, decoration, sample}: {textType: TextType; decoration: Decoration; sample: string}) => { + const [metrics, setMetrics] = React.useState(null) + const lineThrough = decoration === 'strikethrough' || decoration === 'underline+strikethrough' + const underline = decoration === 'underline' || decoration === 'underline+strikethrough' + + const textStyle = Kb.Styles.platformStyles({ + isElectron: { + ...(lineThrough ? ({textDecoration: 'line-through'} as object) : {}), + ...(underline ? ({textDecoration: lineThrough ? 'underline line-through' : 'underline'} as object) : {}), + }, + isMobile: { + ...(lineThrough ? {textDecorationLine: underline ? 'underline line-through' : 'line-through'} : {}), + ...(underline && !lineThrough ? {textDecorationLine: 'underline'} : {}), + }, + }) + + const onTextLayout = Kb.Styles.isMobile + ? (e: {nativeEvent: {lines: ReadonlyArray<{ascender: number; capHeight: number; descender: number; width: number; height: number; xHeight: number}>}}) => { + const lines = e.nativeEvent.lines + if (!lines.length) return + const first = lines[0]! + setMetrics({ + ascender: first.ascender, capHeight: first.capHeight, descender: first.descender, + height: lines.reduce((s, l) => s + l.height, 0), + lineCount: lines.length, width: Math.max(...lines.map(l => l.width)), xHeight: first.xHeight, + }) + } + : undefined + + return ( + + + {textType} + {sample} + + {Kb.Styles.isMobile && metrics ? ( + + {`asc:${metrics.ascender.toFixed(1)} desc:${metrics.descender.toFixed(1)} cap:${metrics.capHeight.toFixed(1)} x:${metrics.xHeight.toFixed(1)} w:${metrics.width.toFixed(1)} h:${metrics.height.toFixed(1)} lines:${metrics.lineCount}`} + + ) : null} + + ) +} + +const Typography = () => { + const [selectedType, setSelectedType] = React.useState('all') + const [decoration, setDecoration] = React.useState('strikethrough') + const [sampleIdx, setSampleIdx] = React.useState(0) + const [darkBg, setDarkBg] = React.useState(false) + + const sample = sampleStrings[sampleIdx % sampleStrings.length]! + const types: ReadonlyArray = selectedType === 'all' ? textTypes : [selectedType] + + return ( + + {/* Controls */} + + + Sample + setSampleIdx(i => i + 1)} /> + + + Type + + setSelectedType('all')} /> + {textTypes.map(t => ( + setSelectedType(t)} /> + ))} + + + + Decoration + + {decorationOptions.map(d => ( + setDecoration(d)} /> + ))} + + + + Background + setDarkBg(v => !v)} label="Dark" /> + + + + + + {types.map(t => )} + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +const styles = Kb.Styles.styleSheetCreate(() => ({ + bold: Kb.Styles.platformStyles({isElectron: {fontWeight: 'bold'} as object, isMobile: {fontWeight: 'bold'}}), + centeredBox: { + backgroundColor: Kb.Styles.globalColors.blue_10, + borderColor: Kb.Styles.globalColors.blue, + borderWidth: 1, + }, + container: {flex: 1}, + controlLabel: {minWidth: 80}, + controlRow: {alignItems: 'center', flexWrap: 'wrap'}, + controls: {padding: Kb.Styles.globalMargins.small}, + darkBg: {backgroundColor: Kb.Styles.globalColors.blueDarker2}, + hint: {color: Kb.Styles.globalColors.black_50, marginBottom: 4}, + innerDivider: {marginBottom: Kb.Styles.globalMargins.xtiny, marginTop: Kb.Styles.globalMargins.xtiny}, + inlineRow: {flexWrap: 'wrap'}, + italic: Kb.Styles.platformStyles({isElectron: {fontStyle: 'italic'} as object, isMobile: {fontStyle: 'italic'}}), + label: {color: Kb.Styles.globalColors.black_50, minWidth: 100}, + lightBg: {backgroundColor: Kb.Styles.globalColors.white}, + metricsText: {color: Kb.Styles.globalColors.blue, fontFamily: 'monospace' as const, marginTop: 2}, + bothDecoration: Kb.Styles.platformStyles({ + isElectron: {textDecoration: 'underline line-through'} as object, + isMobile: {textDecorationLine: 'underline line-through'}, + }), + paraRow: { + borderBottomColor: Kb.Styles.globalColors.black_10, + borderBottomWidth: 1, + paddingBottom: Kb.Styles.globalMargins.xtiny, + paddingTop: Kb.Styles.globalMargins.xtiny, + }, + sampleRow: { + borderBottomColor: Kb.Styles.globalColors.black_10, + borderBottomWidth: 1, + paddingBottom: Kb.Styles.globalMargins.xtiny, + paddingTop: Kb.Styles.globalMargins.xtiny, + }, + samples: {padding: Kb.Styles.globalMargins.small}, + strikethrough: Kb.Styles.platformStyles({ + isElectron: {textDecoration: 'line-through'} as object, + isMobile: {textDecorationLine: 'line-through'}, + }), + underline: Kb.Styles.platformStyles({ + isElectron: {textDecoration: 'underline'} as object, + isMobile: {textDecorationLine: 'underline'}, + }), + wrap: {flexWrap: 'wrap'}, + zoneRow: { + borderBottomColor: Kb.Styles.globalColors.black_10, + borderBottomWidth: 1, + }, + zoneText: Kb.Styles.platformStyles({ + isElectron: {position: 'absolute', top: 0} as object, + }), +})) + +export default Typography diff --git a/shared/tools/fonts/font_tool.py b/shared/tools/fonts/font_tool.py new file mode 100644 index 000000000000..9a1711a52528 --- /dev/null +++ b/shared/tools/fonts/font_tool.py @@ -0,0 +1,832 @@ +#!/usr/bin/env python3 +"""Keybase font tooling CLI.""" + +import argparse +import glob +import json +import re +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +# --- Icon font build constants (match shared/desktop/yarn-helper/font.mts) --- +_ICON_FONT_HEIGHT = 1024 +_ICON_DESCENT = _ICON_FONT_HEIGHT // 16 # 64 +_ICON_BASE_CHAR_CODE = 0xe900 +_ICON_WIN_ASCENT = _ICON_FONT_HEIGHT - _ICON_DESCENT + 2 # 962 +_ICON_WIN_DESCENT = _ICON_DESCENT * 2 + 20 # 148 +_ICON_TYPO_ASCENT = _ICON_FONT_HEIGHT - _ICON_DESCENT # 960 +_ICON_TYPO_DESCENT = -_ICON_DESCENT # -64 +_ICON_HHEA_ASCENT = _ICON_WIN_ASCENT # 962 +_ICON_HHEA_DESCENT = -_ICON_WIN_DESCENT # -148 +_ICON_24_EXTRA_SHIFT = 22 +_ICON_FILENAME_RE = re.compile(r'^(\d+)-kb-iconfont-(.*?)-(\d+)\.svg$') + + +def _draw_svg_path(d: str, pen) -> None: + """Parse an SVG path `d` string and draw into a fonttools SegmentPen.""" + tokens = re.findall( + r'[MmLlHhVvCcSsQqTtZz]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', + d + ) + idx = 0 + cmd = '' + cx, cy = 0.0, 0.0 + prev_x2, prev_y2 = 0.0, 0.0 + contour_open = False + + def is_numeric(t: str) -> bool: + return not re.match(r'[A-Za-z]', t) + + def has_nums(n: int) -> bool: + if idx + n > len(tokens): + return False + return all(is_numeric(tokens[idx + i]) for i in range(n)) + + def nums(n: int) -> list: + nonlocal idx + result = [float(tokens[idx + i]) for i in range(n)] + idx += n + return result + + def ensure_closed(): + nonlocal contour_open + if contour_open: + pen.endPath() + contour_open = False + + _cmd_nums = {'M': 2, 'm': 2, 'L': 2, 'l': 2, 'H': 1, 'h': 1, 'V': 1, 'v': 1, + 'C': 6, 'c': 6, 'S': 4, 's': 4, 'Q': 4, 'q': 4, 'T': 2, 't': 2} + + while idx < len(tokens): + t = tokens[idx] + if re.match(r'[A-Za-z]', t): + cmd = t + idx += 1 + elif cmd in _cmd_nums and not has_nums(_cmd_nums[cmd]): + # Implicit repeat but next token is a command letter — stop repeating. + continue + + if cmd in ('M', 'm'): + ensure_closed() + x, y = nums(2) + if cmd == 'm': + x, y = cx + x, cy + y + pen.moveTo((x, y)) + contour_open = True + cx, cy = x, y + prev_x2, prev_y2 = cx, cy + # Subsequent coords in an M sequence are implicit L/l + cmd = 'L' if cmd == 'M' else 'l' + elif cmd in ('L', 'l'): + x, y = nums(2) + if cmd == 'l': + x, y = cx + x, cy + y + pen.lineTo((x, y)) + cx, cy = x, y + prev_x2, prev_y2 = cx, cy + elif cmd in ('H', 'h'): + x, = nums(1) + if cmd == 'h': + x = cx + x + pen.lineTo((x, cy)) + cx = x + prev_x2, prev_y2 = cx, cy + elif cmd in ('V', 'v'): + y, = nums(1) + if cmd == 'v': + y = cy + y + pen.lineTo((cx, y)) + cy = y + prev_x2, prev_y2 = cx, cy + elif cmd in ('C', 'c'): + x1, y1, x2, y2, x, y = nums(6) + if cmd == 'c': + x1, y1 = cx + x1, cy + y1 + x2, y2 = cx + x2, cy + y2 + x, y = cx + x, cy + y + pen.curveTo((x1, y1), (x2, y2), (x, y)) + cx, cy = x, y + prev_x2, prev_y2 = x2, y2 + elif cmd in ('S', 's'): + x2_rel, y2_rel, x_rel, y_rel = nums(4) + if cmd == 's': + x2_final = cx + x2_rel + y2_final = cy + y2_rel + x_final = cx + x_rel + y_final = cy + y_rel + else: + x2_final, y2_final = x2_rel, y2_rel + x_final, y_final = x_rel, y_rel + x1_final = 2 * cx - prev_x2 + y1_final = 2 * cy - prev_y2 + pen.curveTo((x1_final, y1_final), (x2_final, y2_final), (x_final, y_final)) + prev_x2, prev_y2 = x2_final, y2_final + cx, cy = x_final, y_final + elif cmd in ('Z', 'z'): + if contour_open: + pen.closePath() + contour_open = False + prev_x2, prev_y2 = cx, cy + else: + idx += 1 # unknown command, skip + + ensure_closed() + + +def _build_icon_glyph(svg_path: str, size: int) -> tuple: + """ + Parse one SVG icon file and return (TTGlyph, advance_width, lsb). + size is the icon grid size (8, 16, or 24). + """ + from fontTools.pens.ttGlyphPen import TTGlyphPen + from fontTools.pens.transformPen import TransformPen + from fontTools.pens.cu2quPen import Cu2QuPen + + scale = _ICON_FONT_HEIGHT / size + # SVG y=0 (top) maps to TYPO_ASCENT; 24-size icons shift lower to match original fontforge placement + y_shift = _ICON_TYPO_ASCENT + if size == 24: + y_shift -= _ICON_24_EXTRA_SHIFT + + tt_pen = TTGlyphPen(None) + cu2qu_pen = Cu2QuPen(tt_pen, max_err=1.0, reverse_direction=False) + + ns = 'http://www.w3.org/2000/svg' + tree = ET.parse(svg_path) + root = tree.getroot() + + # Build a parent map so we can walk up from to collect + parent_map = {child: parent for parent in root.iter() for child in parent} + + _translate_re = re.compile(r'translate\(\s*([-\d.]+)(?:[,\s]\s*([-\d.]+))?\s*\)') + + for elem in root.iter(f'{{{ns}}}path'): + d = elem.get('d', '').strip() + if not d: + continue + + # Accumulate translate offsets from the element itself and ancestor elements + tx, ty = 0.0, 0.0 + node = elem # start with the element itself, then walk ancestors + while node is not None and node is not root: + t_attr = node.get('transform', '') + if t_attr: + m = _translate_re.search(t_attr) + if m: + tx += float(m.group(1)) + ty += float(m.group(2) or 0) + node = parent_map.get(node) + + # Compose SVG translate into the scale+flip affine matrix: + # x_font = (x_svg + tx) * scale + # y_font = y_shift - (y_svg + ty) * scale + transform_pen = TransformPen(cu2qu_pen, (scale, 0, 0, -scale, tx * scale, y_shift - ty * scale)) + _draw_svg_path(d, transform_pen) + + advance_width = int(round(size * scale)) # = 1024 for all sizes + glyph = tt_pen.glyph(dropImpliedOnCurves=True) + if glyph.numberOfContours != 0: + glyph.recalcBounds(None) + glyph_width = glyph.xMax - glyph.xMin + if glyph_width > advance_width: + # Scale down uniformly around the horizontal center so the glyph + # fits within the advance width (some SVGs have content that extends + # slightly outside their viewBox). + fit_scale = advance_width / glyph_width + center_x = advance_width / 2 + dx = center_x * (1 - fit_scale) + glyph.coordinates.scale((fit_scale, fit_scale)) + glyph.coordinates.translate((dx, 0)) + glyph.recalcBounds(None) + lsb = glyph.xMin + else: + lsb = 0 + return glyph, advance_width, lsb + + +def _ttfont(path: str): + from fontTools.ttLib import TTFont + return TTFont(path) + + +def _os2_flags(os2): + flags = {} + fs_selection = os2.fsSelection + flags["USE_TYPO_METRICS"] = bool(fs_selection & (1 << 7)) + flags["WOFF_OBLIQUE"] = bool(fs_selection & (1 << 9)) + return flags + + +def inspect_font(path: str) -> dict: + font = _ttfont(path) + + result: dict = {"path": path, "tables": {}} + + # name table + name_table = font["name"] + names = {} + for record in name_table.names: + try: + val = record.toUnicode() + except Exception: + continue + key = f"{record.nameID}" + if key not in names: + names[key] = val + result["tables"]["name"] = names + + # head + head = font["head"] + result["tables"]["head"] = { + "unitsPerEm": head.unitsPerEm, + "macStyle": head.macStyle, + "lowestRecPPEM": head.lowestRecPPEM, + "indexToLocFormat": head.indexToLocFormat, + } + + # hhea + hhea = font["hhea"] + result["tables"]["hhea"] = { + "ascender": hhea.ascender, + "descender": hhea.descender, + "lineGap": hhea.lineGap, + "caretSlopeRise": hhea.caretSlopeRise, + "caretSlopeRun": hhea.caretSlopeRun, + } + + # OS/2 + os2 = font["OS/2"] + result["tables"]["OS/2"] = { + "version": os2.version, + "xAvgCharWidth": os2.xAvgCharWidth, + "usWeightClass": os2.usWeightClass, + "usWidthClass": os2.usWidthClass, + "fsType": os2.fsType, + "sTypoAscender": os2.sTypoAscender, + "sTypoDescender": os2.sTypoDescender, + "sTypoLineGap": os2.sTypoLineGap, + "usWinAscent": os2.usWinAscent, + "usWinDescent": os2.usWinDescent, + "ySubscriptXSize": os2.ySubscriptXSize, + "ySubscriptYSize": os2.ySubscriptYSize, + "ySubscriptXOffset": os2.ySubscriptXOffset, + "ySubscriptYOffset": os2.ySubscriptYOffset, + "ySuperscriptXSize": os2.ySuperscriptXSize, + "ySuperscriptYSize": os2.ySuperscriptYSize, + "ySuperscriptXOffset": os2.ySuperscriptXOffset, + "ySuperscriptYOffset": os2.ySuperscriptYOffset, + "yStrikeoutSize": os2.yStrikeoutSize, + "yStrikeoutPosition": os2.yStrikeoutPosition, + "sFamilyClass": os2.sFamilyClass, + "fsSelection": os2.fsSelection, + "flags": _os2_flags(os2), + "sxHeight": getattr(os2, "sxHeight", None), + "sCapHeight": getattr(os2, "sCapHeight", None), + "usDefaultChar": getattr(os2, "usDefaultChar", None), + "usBreakChar": getattr(os2, "usBreakChar", None), + "usMaxContext": getattr(os2, "usMaxContext", None), + } + + # post + post = font["post"] + result["tables"]["post"] = { + "formatType": post.formatType, + "italicAngle": post.italicAngle, + "underlinePosition": post.underlinePosition, + "underlineThickness": post.underlineThickness, + "isFixedPitch": post.isFixedPitch, + } + + # gasp + if "gasp" in font: + gasp = font["gasp"] + result["tables"]["gasp"] = { + "version": gasp.version, + "gaspRange": {str(k): v for k, v in gasp.gaspRange.items()}, + } + + # cmap: summarize platform/encoding entries + cmap_table = font["cmap"] + cmap_entries = [] + for table in cmap_table.tables: + cmap_entries.append({ + "platformID": table.platformID, + "platEncID": table.platEncID, + "format": table.format, + "numGlyphs": len(table.cmap), + }) + result["tables"]["cmap"] = cmap_entries + + # glyph count and bounds summary + glyf = font.get("glyf") + glyph_order = font.getGlyphOrder() + result["glyphCount"] = len(glyph_order) + if glyf is not None: + bounds_list = [] + for name in glyph_order: + g = glyf[name] + if hasattr(g, "xMin") and g.numberOfContours != 0: + bounds_list.append((g.xMin, g.yMin, g.xMax, g.yMax)) + if bounds_list: + result["glyphBoundsSummary"] = { + "xMin": min(b[0] for b in bounds_list), + "yMin": min(b[1] for b in bounds_list), + "xMax": max(b[2] for b in bounds_list), + "yMax": max(b[3] for b in bounds_list), + } + + # GSUB/GPOS presence + result["tables"]["GSUB"] = "present" if "GSUB" in font else "absent" + result["tables"]["GPOS"] = "present" if "GPOS" in font else "absent" + + font.close() + return result + + +def _metric_snapshot(path: str) -> dict: + """Extract the metrics we care about from a single font.""" + font = _ttfont(path) + os2 = font["OS/2"] + hhea = font["hhea"] + post = font["post"] + head = font["head"] + snapshot = { + "unitsPerEm": head.unitsPerEm, + "hhea": { + "ascender": hhea.ascender, + "descender": hhea.descender, + "lineGap": hhea.lineGap, + }, + "OS/2": { + "sTypoAscender": os2.sTypoAscender, + "sTypoDescender": os2.sTypoDescender, + "sTypoLineGap": os2.sTypoLineGap, + "usWinAscent": os2.usWinAscent, + "usWinDescent": os2.usWinDescent, + "yStrikeoutPosition": os2.yStrikeoutPosition, + "yStrikeoutSize": os2.yStrikeoutSize, + "usWeightClass": os2.usWeightClass, + "fsSelection": os2.fsSelection, + "USE_TYPO_METRICS": bool(os2.fsSelection & (1 << 7)), + "sxHeight": getattr(os2, "sxHeight", None), + "sCapHeight": getattr(os2, "sCapHeight", None), + }, + "post": { + "underlinePosition": post.underlinePosition, + "underlineThickness": post.underlineThickness, + "italicAngle": post.italicAngle, + }, + } + font.close() + return snapshot + + +def cmd_snapshot_metrics(args): + manifest = json.loads(Path(args.manifest).read_text()) + metrics: dict = {"_comment": "Generated snapshot — do not edit by hand. Run: yarn font:snapshot-metrics", "fonts": {}} + + entries = manifest.get("textFonts", []) + icon = manifest.get("iconFont") + if icon: + entries = entries + [{"id": icon["id"], "source": icon["output"]}] + + repo_root = Path(args.manifest).resolve().parent.parent.parent # shared/fonts/manifest.json -> repo root + for entry in entries: + src = repo_root / entry["source"] + if not src.exists(): + print(f"WARNING: {src} not found, skipping", file=sys.stderr) + continue + metrics["fonts"][entry["id"]] = _metric_snapshot(str(src)) + + output = json.dumps(metrics, indent=2) + Path(args.output).write_text(output) + print(f"Wrote {args.output}", file=sys.stderr) + + +def cmd_diff_metrics(args): + """Summarize changed font table values between two directories.""" + before_dir = Path(args.before) + after_dir = Path(args.after) + + before_files = {p.name: p for p in sorted(before_dir.glob("*.ttf"))} + after_files = {p.name: p for p in sorted(after_dir.glob("*.ttf"))} + + all_names = sorted(before_files.keys() | after_files.keys()) + diffs: list = [] + + for name in all_names: + if name not in before_files: + diffs.append({"font": name, "status": "added"}) + continue + if name not in after_files: + diffs.append({"font": name, "status": "removed"}) + continue + + b = _metric_snapshot(str(before_files[name])) + a = _metric_snapshot(str(after_files[name])) + changes = _diff_snapshots(b, a) + if changes: + diffs.append({"font": name, "changes": changes}) + + output = json.dumps(diffs, indent=2) + if args.output == "-": + print(output) + else: + Path(args.output).write_text(output) + print(f"Wrote {args.output}", file=sys.stderr) + + if not diffs: + print("No metric differences found.", file=sys.stderr) + + +def _diff_snapshots(before: dict, after: dict, prefix: str = "") -> list: + changes = [] + for key, bval in before.items(): + aval = after.get(key) + full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + if isinstance(bval, dict): + changes.extend(_diff_snapshots(bval, aval or {}, full_key)) + elif bval != aval: + changes.append({"field": full_key, "before": bval, "after": aval}) + for key in after: + if key not in before: + full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + changes.append({"field": full_key, "before": None, "after": after[key]}) + return changes + + +FS_SELECTION_USE_TYPO_METRICS = 1 << 7 + + +def _apply_patches(font, patches: dict) -> list[str]: + """Apply buildConfig patches to an open TTFont. Returns list of change descriptions.""" + changes = [] + os2_patches = patches.get("OS/2", {}) + if os2_patches: + os2 = font["OS/2"] + if "USE_TYPO_METRICS" in os2_patches: + target = bool(os2_patches["USE_TYPO_METRICS"]) + current = bool(os2.fsSelection & FS_SELECTION_USE_TYPO_METRICS) + if target != current: + if target: + os2.fsSelection |= FS_SELECTION_USE_TYPO_METRICS + else: + os2.fsSelection &= ~FS_SELECTION_USE_TYPO_METRICS + changes.append(f"OS/2.fsSelection USE_TYPO_METRICS → {target}") + for field in ("sTypoAscender", "sTypoDescender", "sTypoLineGap", + "usWinAscent", "usWinDescent", + "yStrikeoutPosition", "yStrikeoutSize", + "sxHeight", "sCapHeight"): + if field in os2_patches: + old = getattr(os2, field) + setattr(os2, field, os2_patches[field]) + if old != os2_patches[field]: + changes.append(f"OS/2.{field}: {old} → {os2_patches[field]}") + hhea_patches = patches.get("hhea", {}) + if hhea_patches: + hhea = font["hhea"] + for field in ("ascender", "descender", "lineGap"): + if field in hhea_patches: + old = getattr(hhea, field) + setattr(hhea, field, hhea_patches[field]) + if old != hhea_patches[field]: + changes.append(f"hhea.{field}: {old} → {hhea_patches[field]}") + post_patches = patches.get("post", {}) + if post_patches: + post = font["post"] + for field in ("underlinePosition", "underlineThickness"): + if field in post_patches: + old = getattr(post, field) + setattr(post, field, post_patches[field]) + if old != post_patches[field]: + changes.append(f"post.{field}: {old} → {post_patches[field]}") + return changes + + +def cmd_build_text(args): + import shutil + manifest = json.loads(Path(args.manifest).read_text()) + repo_root = Path(args.manifest).resolve().parent.parent.parent + build_cfg = manifest.get("buildConfig", {}) + platform: str = args.platform + + if platform == "android": + out_dir = repo_root / build_cfg.get("androidOutputDir", "shared/fonts/generated/android") + else: + out_dir = repo_root / build_cfg.get("outputDir", "shared/fonts/generated") + + global_patches = build_cfg.get("patches", {}) + out_dir.mkdir(parents=True, exist_ok=True) + + errors = 0 + for entry in manifest.get("textFonts", []): + src = repo_root / entry["source"] + if not src.exists(): + print(f"ERROR: source not found: {src}", file=sys.stderr) + errors += 1 + continue + dest = out_dir / src.name + font = _ttfont(str(src)) + per_font_patches: dict = entry.get("patches", {}) + if platform == "android": + android_patches: dict = entry.get("androidPatches", {}) + per_font_patches = { + table: {**per_font_patches.get(table, {}), **android_patches.get(table, {})} + for table in set(list(per_font_patches.keys()) + list(android_patches.keys())) + } + merged: dict = {} + for table in set(list(global_patches.keys()) + list(per_font_patches.keys())): + merged[table] = {**global_patches.get(table, {}), **per_font_patches.get(table, {})} + changes = _apply_patches(font, merged) + font.save(str(dest)) + font.close() + tag = f" [{', '.join(changes)}]" if changes else " (no changes)" + print(f" {src.name} → {dest}{tag}", file=sys.stderr) + + # Copy to platform destinations where the target directory exists. + # For android platform, use the "android" output key; otherwise copy to all + # non-android outputs (ios + electron) when building the default platform. + outputs: dict = entry.get("outputs", {}) + electron_patches: dict = entry.get("electronPatches", {}) + copy_keys = ["android"] if platform == "android" else [k for k in outputs if k != "android"] + for key in copy_keys: + out_path_rel = outputs.get(key) + if not out_path_rel: + continue + out_path = repo_root / out_path_rel + if out_path == dest: + continue # already the staging dest + out_path.parent.mkdir(parents=True, exist_ok=True) + if key == "electron" and electron_patches and platform != "android": + efont = _ttfont(str(dest)) + echanges = _apply_patches(efont, electron_patches) + efont.save(str(out_path)) + efont.close() + etag = f" [{', '.join(echanges)}]" if echanges else "" + print(f" → {key}: {out_path}{etag}", file=sys.stderr) + elif out_path.parent.exists(): + shutil.copy2(str(dest), str(out_path)) + print(f" → {key}: {out_path}", file=sys.stderr) + + if errors: + print(f"build-text: {errors} error(s)", file=sys.stderr) + sys.exit(1) + else: + # Touch a sentinel file so webpack's filesystem cache knows fonts changed. + sentinel = repo_root / "shared" / "fonts" / ".font-build-stamp" + sentinel.write_text(str(time.time()) + "\n") + print(f"build-text: wrote {out_dir}", file=sys.stderr) + + +def cmd_build_icon(args): + from fontTools.fontBuilder import FontBuilder + from fontTools.ttLib.tables._g_l_y_f import Glyph as TTGlyph + + manifest = json.loads(Path(args.manifest).read_text()) + repo_root = Path(args.manifest).resolve().parent.parent.parent + icon_cfg = manifest.get("iconFont", {}) + iconfont_dir = repo_root / "shared" / "images" / "iconfont" + + # Collect and sort SVG files by counter + entries = [] + for svg_file in sorted(iconfont_dir.glob("*.svg")): + m = _ICON_FILENAME_RE.match(svg_file.name) + if not m: + continue + counter, name, size = int(m.group(1)), m.group(2), int(m.group(3)) + entries.append((counter, name, size, svg_file)) + entries.sort(key=lambda e: e[0]) + + if not entries: + print("ERROR: no SVG files found", file=sys.stderr) + sys.exit(1) + + # Build glyph data + glyph_order = [".notdef"] + char_map: dict[int, str] = {} + glyph_data: dict = {} + h_metrics: dict = {} + + # .notdef: empty glyph + empty = TTGlyph() + empty.numberOfContours = 0 + empty.coordinates = [] + empty.flags = [] + empty.components = [] + glyph_data[".notdef"] = empty + h_metrics[".notdef"] = (_ICON_FONT_HEIGHT, 0) + + seen_counters: set[int] = set() + errors = 0 + for counter, name, size, svg_path in entries: + if counter in seen_counters: + print(f"ERROR: duplicate counter {counter} in {svg_path.name}", file=sys.stderr) + errors += 1 + continue + seen_counters.add(counter) + + glyph_name = f"uni{(_ICON_BASE_CHAR_CODE + counter - 1):04X}" + codepoint = _ICON_BASE_CHAR_CODE + counter - 1 + glyph_order.append(glyph_name) + char_map[codepoint] = glyph_name + + try: + glyph, adv, lsb = _build_icon_glyph(str(svg_path), size) + glyph_data[glyph_name] = glyph + h_metrics[glyph_name] = (adv, lsb) + except Exception as e: + print(f"ERROR building glyph for {svg_path.name}: {e}", file=sys.stderr) + errors += 1 + glyph_data[glyph_name] = empty + h_metrics[glyph_name] = (_ICON_FONT_HEIGHT, 0) + + if errors: + print(f"build-icon: {errors} error(s)", file=sys.stderr) + sys.exit(1) + + # Assemble font + fb = FontBuilder(_ICON_FONT_HEIGHT, isTTF=True) + fb.setupGlyphOrder(glyph_order) + fb.setupCharacterMap(char_map) + fb.setupGlyf(glyph_data) + fb.setupHorizontalMetrics(h_metrics) + fb.setupHorizontalHeader( + ascent=_ICON_HHEA_ASCENT, + descent=_ICON_HHEA_DESCENT, + ) + fb.setupNameTable({ + "familyName": "kb", + "styleName": "Regular", + "fullName": "kb", + "psName": "kb", + }) + fb.setupOS2( + sTypoAscender=_ICON_TYPO_ASCENT, + sTypoDescender=_ICON_TYPO_DESCENT, + sTypoLineGap=0, + usWinAscent=_ICON_WIN_ASCENT, + usWinDescent=_ICON_WIN_DESCENT, + fsType=0, + fsSelection=0, + ) + fb.setupPost(keepGlyphNames=False) + from fontTools.ttLib.tables._g_a_s_p import table__g_a_s_p + gasp = table__g_a_s_p() + gasp.version = 1 + gasp.gaspRange = {65535: 15} + fb.font["gasp"] = gasp + + # Determine output paths from manifest iconFont config + outputs = icon_cfg.get("outputs", [icon_cfg.get("output", "")]) + if isinstance(outputs, str): + outputs = [outputs] + + platform: str = args.platform + android_output = icon_cfg.get("androidOutput", "") + + wrote = [] + if platform == "android": + if android_output: + out_path = repo_root / android_output + out_path.parent.mkdir(parents=True, exist_ok=True) + fb.font.save(str(out_path)) + wrote.append(str(out_path)) + else: + print("WARNING: --platform android but iconFont.androidOutput not set in manifest", file=sys.stderr) + else: + for rel in outputs: + if not rel: + continue + out_path = repo_root / rel + out_path.parent.mkdir(parents=True, exist_ok=True) + fb.font.save(str(out_path)) + wrote.append(str(out_path)) + + for w in wrote: + print(f" wrote {w}", file=sys.stderr) + print(f"build-icon: {len(entries)} glyphs, {len(wrote)} output(s)", file=sys.stderr) + + # Touch sentinel so webpack notices font change + if wrote: + sentinel = repo_root / "shared" / "fonts" / ".font-build-stamp" + sentinel.write_text(str(time.time()) + "\n") + + +def cmd_verify_text(args): + manifest = json.loads(Path(args.manifest).read_text()) + expected = json.loads(Path(args.metrics).read_text()) + repo_root = Path(args.manifest).resolve().parent.parent.parent + + # verify-text checks the generated dir if it exists, otherwise the source + build_cfg = manifest.get("buildConfig", {}) + generated_dir = repo_root / build_cfg.get("outputDir", "shared/fonts/generated") + + failures: list[str] = [] + checked = 0 + for entry in manifest.get("textFonts", []): + font_id = entry["id"] + if font_id not in expected.get("fonts", {}): + print(f" SKIP {font_id}: not in metrics.json", file=sys.stderr) + continue + src = repo_root / entry["source"] + generated = generated_dir / Path(entry["source"]).name + path = generated if generated.exists() else src + if not path.exists(): + failures.append(f"{font_id}: source not found at {path}") + continue + actual = _metric_snapshot(str(path)) + exp = expected["fonts"][font_id] + mismatches = _diff_snapshots(exp, actual) + if mismatches: + for m in mismatches: + failures.append(f"{font_id}: {m['field']} expected={m['before']} got={m['after']}") + else: + print(f" OK {font_id} ({path.name})", file=sys.stderr) + checked += 1 + + if failures: + print(f"\nverify-text: {len(failures)} failure(s):", file=sys.stderr) + for f in failures: + print(f" FAIL {f}", file=sys.stderr) + sys.exit(1) + else: + print(f"verify-text: all {checked} fonts pass", file=sys.stderr) + + +def cmd_inspect(args): + inputs: list[str] = [] + for pattern in args.inputs: + expanded = glob.glob(pattern) + if expanded: + inputs.extend(expanded) + else: + inputs.append(pattern) + + results = [] + for path in sorted(inputs): + try: + results.append(inspect_font(path)) + except Exception as e: + results.append({"path": path, "error": str(e)}) + + output = json.dumps(results, indent=2) + if args.output == "-": + print(output) + else: + Path(args.output).write_text(output) + print(f"Wrote {args.output}", file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser( + prog="font_tool", + description="Keybase font tooling", + ) + sub = parser.add_subparsers(dest="command", required=True) + + p_inspect = sub.add_parser("inspect", help="Inspect fonts and emit table data as JSON") + p_inspect.add_argument("--inputs", nargs="+", required=True, metavar="PATTERN", + help="Font file paths or glob patterns") + p_inspect.add_argument("--output", default="-", metavar="FILE", + help="Output JSON file (default: stdout)") + p_inspect.set_defaults(func=cmd_inspect) + + p_build = sub.add_parser("build-text", help="Build text fonts from canonical inputs") + p_build.add_argument("--manifest", required=True, metavar="FILE") + p_build.add_argument("--platform", default="all", choices=["all", "android"], + help="Target platform (default: all; 'android' applies androidPatches)") + p_build.set_defaults(func=cmd_build_text) + + p_verify = sub.add_parser("verify-text", help="Verify text fonts against manifest and metrics") + p_verify.add_argument("--manifest", required=True, metavar="FILE") + p_verify.add_argument("--metrics", required=True, metavar="FILE") + p_verify.set_defaults(func=cmd_verify_text) + + p_snap = sub.add_parser("snapshot-metrics", help="Generate metrics.json from current fonts") + p_snap.add_argument("--manifest", required=True, metavar="FILE", + help="Path to shared/fonts/manifest.json") + p_snap.add_argument("--output", required=True, metavar="FILE", + help="Output metrics JSON file") + p_snap.set_defaults(func=cmd_snapshot_metrics) + + p_diff = sub.add_parser("diff-metrics", help="Summarize changed font table values between two directories") + p_diff.add_argument("--before", required=True, metavar="DIR", help="Directory with original TTFs") + p_diff.add_argument("--after", required=True, metavar="DIR", help="Directory with new TTFs") + p_diff.add_argument("--output", default="-", metavar="FILE", + help="Output JSON file (default: stdout)") + p_diff.set_defaults(func=cmd_diff_metrics) + + p_icon = sub.add_parser("build-icon", help="Build kb.ttf icon font from SVGs") + p_icon.add_argument("--manifest", required=True, metavar="FILE") + p_icon.add_argument("--platform", default="all", choices=["all", "android"], + help="Target platform (default: all; 'android' writes androidOutput)") + p_icon.set_defaults(func=cmd_build_icon) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/shared/tools/fonts/requirements.txt b/shared/tools/fonts/requirements.txt new file mode 100644 index 000000000000..c6d92a4d88c2 --- /dev/null +++ b/shared/tools/fonts/requirements.txt @@ -0,0 +1 @@ +fonttools==4.60.2