Skip to content

Consistent emojis, reworked bookmarks, and navigation ordering - #1158

Closed
mikebarkmin wants to merge 12 commits into
mainfrom
claude/hyperbook-mailto-tel-links-5rdklw
Closed

Consistent emojis, reworked bookmarks, and navigation ordering#1158
mikebarkmin wants to merge 12 commits into
mainfrom
claude/hyperbook-mailto-tel-links-5rdklw

Conversation

@mikebarkmin

Copy link
Copy Markdown
Member

A branch that grew from a mailto: bug into four related changes. Each commit stands on its own, and each is covered by a changeset.

Links

makeUrl only passed a URL through untouched when it contained :// or started with data:, so every other scheme was resolved against the base path and [Write us](mailto:a@b.c) became href="/mailto:a@b.c". Links configured in hyperbook.json were unaffected, because those hrefs are used verbatim.

An isExternalUrl helper in @hyperbook/types now matches any RFC 3986 scheme, used by the builder and the VS Code preview. This covers mailto: and tel: as well as sms: and geo:, and keeps https:// and data: working as before. It also fixes the [get in touch](mailto:…) links on this repository's own website.

Emojis

Emojis were resolved to plain characters and drawn with whatever emoji font the reader's operating system provides, so the same book looked different on Windows, macOS, Android and Linux.

elements.emoji.style: "twemoji" replaces them with Twemoji images at build time. It covers emojis in the content as well as icons from the config, leaves code untouched, and only copies the emojis a book actually uses into its output — a full build of the documentation copies 1268 of the 3720 images. The default stays "native", so an existing book renders exactly as before. The website opts in.

Characters that are text by default, like © or ™, are only replaced when the author asked for the emoji presentation, and an emoji without a Twemoji asset is left as text.

Bookmarks

A bookmark label used to be interpolated into an onclick attribute and rendered with innerHTML. Three problems: a heading containing a quote or a backslash produced a broken JavaScript string and the button threw, the label was persisted markup that came back through innerHTML, and the label was the markdown text rather than what the page renders, so an emoji drawn as an image was a plain character again in the bookmark list.

Labels are now read from the rendered heading and stored as parts:

[{ text: "Getting started " }, { text: "🐧", emoji: "1f427" }]

The list builds its entries from those parts with the DOM, so nothing that was stored is parsed as HTML, and an emoji keeps the look it has on the page. Emojis are stored by id rather than by URL, so a bookmark survives a change of the basePath.

The bookmark indicator is no longer the 🔖 character in two places — the button on a heading and a ::before in the list. Both are drawn from the stylesheet with a mask, so the control looks the same everywhere, takes the color of its heading in light and dark mode, and does not follow the emoji setting. A saved bookmark shows a filled icon instead of only a less transparent one.

Breaking:

  • hyperbook.ui.toggleBookmark(key, label) no longer takes a label
  • bookmark buttons carry no inline onclick; they are handled by a delegated listener and carry data-key, data-label, aria-label and aria-pressed
  • bookmark buttons are empty, so a custom style that replaced the emoji has to be updated
  • the store moves to version 6, migrating existing labels; a plain label is still rendered, so nothing is lost

Navigation

Pages were rendered before the sections of the same level, so a page could not sit after or between them. The index of a page and the index of a section are one order now, at every level, so a subsection can sit between two pages of its section. The reading order follows the same order, so the previous and next buttons, the breadcrumb and ::pagelist agree with the sidebar — that order lives in one place instead of once in the shell and twice in a page list.

Without an index nothing moves: a page still comes before a section, and a page wins a tie. A book that indexes its sections but leaves it off a page does move that page behind them, which is why the documentation now gives its changelog page an index and shifts the sections after it. Both website books render the same navigation as before.

The pages of a section are also indented now. They sat one pixel to the right of a page of the level above, which was harmless while every page came before every section.

Loading

Two fixes for a moment of collapsed layout on load:

  • A custom element renders its children until it is upgraded, and a side drawer is only hidden by its shadow root, so the search drawer painted over the header and the table of contents drawer over the article until side-drawer.js had run. Measured while undefined, that was 500x98 over the header and 500x216 over the article.
  • The script that writes the light and dark stylesheets was a file. Nothing can paint before those stylesheets are there, so every first paint waited for a round trip to fetch 1.5 kB and then another for the stylesheets themselves. It is part of the page now, which measured the first contentful paint of the documentation going from 1128 ms to 984 ms over a connection throttled to 1.5 Mbit/s with 100 ms of latency.

Testing

272 tests pass. New tests cover the link schemes, the emoji substitution and its guards, the bookmark button markup including a heading with a quote, and the navigation order at the top level and inside a section.

Beyond that, everything visual was checked in Chromium against a real build: the bookmark round trip and its store migration from a seeded version 5 database, the emoji styles in light and dark mode, the drawer flash before and after, the first paint measurement, and the navigation with three levels of subsections.

The branch name is left over from the first commit and no longer describes the contents.


Generated by Claude Code

claude added 12 commits August 7, 2026 07:21
makeUrl only passed URLs through untouched when they contained "://" or
started with "data:". Every other scheme was treated as a relative path,
so a markdown link like [Write us](mailto:a@b.c) was joined with the base
path and rendered as href="/mailto:a@b.c". Links configured in
hyperbook.json were unaffected, because those hrefs are used verbatim.

Add an isExternalUrl helper to @hyperbook/types that matches any RFC 3986
scheme, and use it in the makeUrl implementations of the builder and the
VS Code preview. This covers mailto: and tel: as well as sms:, geo: and
friends, while https:// and data: keep working as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
Emojis were resolved to plain Unicode characters and drawn by whatever
emoji font the reader's operating system provides, so the same book looked
different on Windows, macOS, Android and Linux.

Add a rehypeEmoji plugin that swaps emojis for Twemoji images when
elements.emoji.style is set to "twemoji". It runs after the shell, so
icons configured in hyperbook.json are covered along with the content,
and it skips pre, code, script, style, textarea and title. Characters
that are text by default, like (c) or (tm), are only replaced when the
author asked for the emoji presentation, and an emoji without a Twemoji
asset is left as text.

The full Twemoji set ships in the package assets, but a build copies only
the images a book actually uses into its output, both for a full build
and for an incremental rebuild in the dev server. The default stays
"native", so existing books render exactly as before.

Twemoji graphics are CC-BY 4.0 by Twitter, Inc and other contributors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
A bookmark label was interpolated into the onclick attribute of the
bookmark button and rendered with innerHTML. That had three problems: a
heading containing a quote or a backslash produced a broken JavaScript
string and the button threw, the label was persisted markup that came
back through innerHTML, and the label was the markdown text rather than
what the page renders, so an emoji drawn as an image was a plain
character again in the bookmark list.

Labels are now read from the rendered heading when a bookmark is saved
and stored as parts, each with its text and, for an emoji, its id. The
bookmark list builds its entries from those parts with the DOM, so
nothing that was stored is parsed as HTML, emojis keep the look they
have on the page, and the id rather than a URL means a bookmark survives
a change of the basePath.

The button lost its inline onclick and is handled by one delegated
listener, which also covers headings that are added later. It carries
data-key, a data-label fallback and aria-pressed.

The store moves to version 6, which drops the pointless index on the
label and migrates existing labels to the new shape. A plain string
label is still rendered, so bookmarks from an older version and from an
older export keep working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
The indicator was the 🔖 character in two places: as the content of the
button on a heading, and as the content of a ::before in the bookmark
list. Both were drawn with the emoji font of the reader, so the control
looked different on every platform. The one in the button also changed
into a Twemoji image once a hyperbook set the emoji style, which made a
piece of interface follow a content setting.

Both are now a mask over the feather bookmark icon, so the control looks
the same everywhere and stays a control. It takes the color of its
surroundings, which keeps it readable in light and dark mode, and a
bookmarked heading shows a filled icon instead of only a less
transparent one.

The button carries an aria-label now, since it no longer has content to
name it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
The icon inherited its color, which picked up the color of the h1 rather
than the color the heading appears in: the heading text is a link, and
the button is its sibling, so inheriting missed the brand color the
reader actually sees. Take the same color the heading link takes, which
also keeps the icon in step with the brand color of the hyperbook and
with the dark mode variant of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
Dogfoods the emoji style on the documentation, so the emojis in the
docs, the language flags of the library and the icons of the custom
links look the same for every reader.

Also fixes a crash the website build turned up: an element that carries
no children at all, which a directive can produce for a void element
like an img, made the emoji walk throw. It is skipped now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
A custom element renders its children until it is upgraded, and a side
drawer keeps its closed state in its shadow root. The script that defines
it is an async module, so it runs after the first paint: until then the
search drawer painted over the header and the table of contents drawer
over the article, which read as a collapsed layout for a moment.

Measured while undefined, the search drawer was 500x98 over the header
and the table of contents drawer 500x216 over the article. The nav
drawer was not visible on a wide screen, because it sits in the mobile
nav, which is hidden there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
The light and dark stylesheets sit in a noscript element and are written
into the page by a script. Nothing can paint before they are there, so
loading that script as a file put a round trip in front of every first
paint, and a second one in front of the stylesheets it writes.

The script is 1.5 kB, so it is part of the page now. On the
documentation, over a connection throttled to 1.5 Mbit/s with 100 ms of
latency, the first contentful paint went from a median of 1128 ms to
984 ms over seven runs each.

The behaviour is unchanged: a mode the reader picked before still wins
over the system preference, in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
Pages were rendered before the sections of the same level, so a page
could not sit after or between them. The index of a page and the index
of a section are one order now, and the navigation renders a run of
pages as a list, broken by every section that falls between them.

The reading order follows the same order, so the previous and next
buttons, the breadcrumb and pagelist agree with the sidebar. That order
lives in one place now, instead of once in the shell and twice in the
page list.

Without an index nothing moves: a page still comes before a section, and
a page wins a tie against a section. A book that indexes its sections
but not a page does move that page behind them, which is why the
documentation now gives its changelog page an index and shifts the
sections after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
A page of a section sat one pixel to the right of a page of the level
above, so the only thing telling the two levels apart was the hairline
on the left of the list. That was harmless while every page came before
every section, but a page can sit after a section now, and it read as
one more page of that section.

The pages of a section are indented by 0.75rem, which puts them 13px in
instead of 1px, and a section block keeps a little room below it. Both
are scoped to a real section, so a virtual section still renders its
pages at the level of its parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
Only the top level of the navigation shared one order. Inside a section
the subsections were still rendered after every page of that section, so
a subsection could not sit between two pages, and the sidebar disagreed
with the previous and next buttons, which already followed the index.

One function renders a level now and calls itself through the section it
renders, which also drops the second copy of the merge that existed for
sections shown as a page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012kkjvqifo7fQ5W6woRWiSW
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperbook-4fwa Ready Ready Preview Aug 7, 2026 11:53am

Copy link
Copy Markdown
Member Author

Superseded by #1159.

The links, the emojis and the bookmarks from this branch went out with #1153 and shipped as v0.101.0, so what was left is the navigation order and the two load fixes. #1159 carries those five commits on top of the current main.


Generated by Claude Code

@mikebarkmin mikebarkmin closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants