Skip to content

Release v1.8.0: RoyalRoad redesign support, dependency and security updates - #138

Merged
Seismix merged 12 commits into
mainfrom
dev
Jul 23, 2026
Merged

Release v1.8.0: RoyalRoad redesign support, dependency and security updates#138
Seismix merged 12 commits into
mainfrom
dev

Conversation

@Seismix

@Seismix Seismix commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Release v1.8.0 — support for RoyalRoad's new beta layout, plus a dependency and security pass.

Important

Merge this with "Squash and merge" — it is the only method this repo allows
(main enforces required_linear_history, and merge/rebase merges are disabled).

Squashing flattens dev's history into one commit on main, which breaks the
ancestry link between the branches: the next dev -> main merge would otherwise
re-apply changes main already has and conflict on every touched file.

After merging, reconcile the branches so the next release merges cleanly:

git checkout dev && git pull && git merge -s ours origin/main -m "chore: reconcile squash-merged main into dev" && git push origin dev

This records main as an ancestor without changing any files. It is a standing
post-release step for this workflow, not a one-time repair.

Changes

Redesign support (#136)

  • Recaps and blurbs now work on RoyalRoad's new beta layout as well as the classic one
  • The extension detects which layout was served and adapts on its own, so switching layouts with RoyalRoad's own control just works
  • Custom CSS selectors are stored per layout, so a tweak for one cannot break the other
  • Layouts are pluggable via an adapter registry (src/lib/adapters/)

Fixes (#136)

  • The page no longer jumps to the recap while it is still loading
  • The report link sits next to RoyalRoad's own report button on the new layout, and is readable on both light and dark themes
  • The "Reduced Motion detected" note now appears with the scroll setting it actually affects

Dependencies and security (#137)

  • pnpm audit reduced from 82 advisories (1 critical, 34 high) to 1; pnpm audit --prod from 17 to 0
  • dompurify 3.3.1 -> 3.4.12 clears every production advisory. It is the only dependency that ships in the bundle.
  • Test DOM moved to jsdom — DOMPurify >= 3.4 mis-walks the DOM under happy-dom, which had silently defeated the sanitizer test. Verified correct in real Chromium; the shipped extension was never affected.
  • The one remaining advisory is yauzl (moderate), which has no patched version published.

Testing

On merge

Merging this to main triggers publish.yaml, which will:

  1. Build and submit the Firefox Add-ons package
  2. Build and submit the Chrome Web Store package (credentials are configured, so this will run)
  3. Tag v1.8.0 and create a GitHub release, once both stores succeed

Related Issues

Includes #136 and #137.

Closes #135 — "Scrolling before recap loaded is jarring", fixed by deferring the scroll until the recap has finished fetching.

* feat: add zip configuration to exclude .env files

* feat: exclude playwright dirs from firefox sources zip
* fix: refactor enableJump handling in defaults and migrations

* fix: update import paths for consistency in Options.svelte

* fix: improve messaging for reduced motion settings in BasicSettings.svelte

* refactor: update scroll behavior settings to Auto/Instant

- change existing "Animated" option to "Auto (Recommended)" in UI
- remove complex first-install Reduced Motion detection logic
- allow users to select Auto scroll even if Reduced Motion is enabled (with info message)
- simplify default settings handling

* feat: update version to 1.6.0 and add release notes for enhanced scrolling options and accessibility improvements

* feat: refactor migration from v1 to v2 for smoothScroll settings and add corresponding tests

* chore: format

* fix: remove unused import for devLog in defaults.ts
* fix: update screenshot in readme

* chore: update dependancies

* refactor: update icons to fix svelte and build warnings

* chore: format code

* chore: update pull request branches to include dev
This is to align with the rest of the codebase.
* feat: add FlagIcon component

* feat: add ReportButton component

Opens a pre-filled Google Form with the chapter URL and current date
for users to report broken recaps.

* feat: show report button in popup on chapter pages

Detects the active tab URL and conditionally renders the ReportButton
in the popup header when the user is on a RoyalRoad chapter page.

* feat: wire up Google Form for popup report button

Replace placeholder form URL and field IDs with actual Google Form.
Remove date field since Google Sheets adds a timestamp automatically.

* feat: add openUrl message handler to background script

* feat: add report broken recap link to chapter pages

Inject a report link into the chapter page sidebar that opens
a pre-filled Google Form with the current chapter URL.

* chore: bump version to 1.7.0 and add patch notes

Add patch notes for bug reporting feature release.

* style: pnpm format

* refactor: extract shared report form config and remove unused handler

Move Google Form URL and builder to shared report-form.ts config.
Remove unused openUrl message handler from background script.

* chore: split host_permissions out of permissions in wxt.config

Required for proper MV3 compliance. Note: existing users will be
prompted to re-approve the royalroad.com host permission on update.

* feat: enrich broken-report with version/browser/type and blurb support

Extend buildReportFormUrl to prefill version, browser, and content type
alongside the chapter URL, and support reporting broken blurbs in
addition to recaps. Introduce a shared ContentType ("recap" | "blurb")
type, extract isChapterUrl into dom-utils, and expose the report link
placement in advanced settings. Adds unit tests for URL building.

NOTE: version/browser/type Google Form entry IDs are still placeholders
(entry.PLACEHOLDER_*) and must be replaced with real field IDs before
those prefills will populate.

* feat: wire real Google Form entry IDs for version/browser/type prefill

Replace placeholder entry IDs with the real field IDs from the report
form (Version, Browser type, Content Type), so all four values now
prefill correctly.

* chore: pin pnpm build-script approvals

Record approved dependency build scripts (esbuild enabled, spawn-sync
disabled) via pnpm approve-builds so contributors and CI share the same
build policy.

* chore: add .gitattributes to normalize line endings to LF

Pin text files to LF in the repo and working trees so Windows
core.autocrlf no longer produces phantom CRLF-only diffs. Mark binary
asset types so their bytes are left untouched.

* docs: update v1.7.0 patch notes date and content

Set release date to 2026-06-21 and reflect what actually shipped: blurb
reporting in addition to recaps, and auto-included version/browser
diagnostics.

* ci: prevent needrestart hang and add timeout to CSS Selectors workflow

Set NEEDRESTART_MODE=a / NEEDRESTART_SUSPEND=1 so `playwright install
--with-deps` no longer hangs on the needrestart prompt on Ubuntu 24.04,
and add timeout-minutes: 15 so any future hang fails fast instead of
squatting a runner. Closes #127.

* ci: upgrade @playwright/test to 1.61.0 to fix browser-install hang

@playwright/test 1.57.0 is affected by a regression (Playwright
1.55.1 to <1.60.0) where `playwright install` downloads Chromium then
hangs indefinitely on system-deps, stalling the CSS Selectors workflow
until the runner times out. Upgrade to 1.61.0, which includes the fix.

Ref: microsoft/playwright#40998
Publishes to the Chrome Web Store and Firefox Add-ons on merge to main
(or via manual dispatch). Builds the zips, runs wxt submit, then tags
the version and creates a GitHub release. Idempotent: skips if the
version is already tagged. Chrome activates once a refresh token secret
is present; Firefox publishes independently.

Closes #96
* feat: add loading state for recap and blurb fetches

Show a centered "Loading…" indicator while a fetch is in flight, scroll
to it as soon as it appears (rather than waiting for content), and
disable the toggle to ignore repeat clicks during an in-flight fetch.

Loading and error states reuse RoyalRoad's Bootstrap utility classes
(text-muted/text-danger) so they inherit the page theme on light and
dark backgrounds instead of fighting it with inline overrides.

* feat: make recap fetching resilient to timeouts and cache misses

Abort fetches that hang past 15s so a stuck connection surfaces a real
error instead of an indefinite loading state, and map HTTP/network
failures to reader-friendly messages.

When a word-count change misses the cache (expired TTL), re-fetch the
chapter instead of silently leaving stale content on screen. Pass the
fetcher's messages straight through rather than double-wrapping them.

* refactor: clarify user-facing error messages and guard sanitization

Reword selector-failure messages to plain language and point readers to
the Report button when RoyalRoad's layout has likely changed. Guard
against the sanitizer silently swallowing all content, surfacing an
explicit error instead of an empty recap.

* chore: bump version to 1.7.1
… e2e) (#130)

* test: configure Vitest runner and split Playwright projects

Add Vitest (happy-dom) via WXT's WxtVitest plugin for fast unit/component
tests, with @testing-library/svelte and an explicit Svelte transform.
Separate the two runners: Vitest collects *.unit.test.ts / *.svelte.test.ts,
while Playwright is split into an `e2e` project (mocked) and a `canary`
project (live), with testIgnore so neither runner double-collects.

* test: add unit and component test suites

Cover the core logic with Vitest: ContentProcessor (word-count selection
from the end, truncation marker, sanitization, error branches), ContentCache
TTL/expiry, ContentManager cache hit/miss, dom-utils URL resolution, plus the
recap-state runes and the ToggleButton/RecapContainer/ReportLink components.
Port the migrations and report-form tests off the Playwright runner onto
Vitest and remove their old copies.

* test: add mocked Playwright e2e suite and fixtures

Drive the real built extension while fulfilling all royalroad.com requests
from hand-built HTML fixtures via page.route, so the e2e suite is fast and
offline: recap injection/render, the blurb path, and the fetch-error state.
Fix the extension-loading fixture to point at .output/chrome-mv3 and load the
unpacked extension via channel: "chromium".

* test: skip live selector canary when RoyalRoad is unreachable

Distinguish a third-party outage from a real selector regression: on a
network/DNS/timeout failure the canary skips with a "re-run once the site is
back up" message instead of failing, while a loaded-but-missing selector
still fails loudly.

* ci: split test workflows and isolate live selector canary

Run fast Vitest unit/component tests (unit.yaml) and mocked Playwright e2e
tests (e2e.yaml) on every non-draft PR into dev/main. Repurpose the existing
css-selectors workflow as the live canary: PR-triggered with a source path
filter and draft guard, plus manual dispatch.
…yout is served (#136)

* chore: ignore local env files

* feat: support RoyalRoad redesign (beta) via UI-adapter split

Adds a UI-adapter (Strategy) layer so the extension works on both the legacy
and "Redesign (beta)" RoyalRoad layouts. Version-agnostic services (fetch,
cache, process, mount) consume a resolved adapter; legacy/redesign DOM knowledge
and selectors live behind LegacyAdapter/RedesignAdapter. Selectors are stored as
per-UI overrides (migrated from the old flat set) and remain user-editable.

Also adds a RoyalRoad layout switch backed by an editable, on-demand cookie
(optional `cookies` permission): Redesign forces `always`, Classic forces
`never`, clearing every cookie scope so RoyalRoad's own cookie can't win. The
selector is seeded from the live cookie on open. Removes the injected report
link (reporting lives in the popup).

* fix: move reduced-motion banner under the scroll behavior control

* fix: tidy adapter resolution, component teardown, and cookie sync

- mountComponent: unmount the Svelte 5 component on cleanup instead of
  the dead Svelte 4 $destroy branch, so injected components tear down
- isRedesign: honor the user-editable cookie name/value from settings
  (threaded via resolveAdapter/buildPageContext) and match name=value
  exactly instead of a loose substring check
- background: skip re-applying the gating cookie when only unrelated
  settings change

* chore: apply prettier formatting

* fix(adapters): revert host-page tweaks on teardown and scope prev-chapter selector

`resolveMounts` restyled RoyalRoad's nav bar (flex-wrap, position, classes) with
nothing to undo it, so disabling or updating the extension left the page mutated
until reload. Renamed to `prepareMounts` to make the side effect visible at the
call site, and it now returns a `cleanup` that restores the previous inline
styles and removes only the classes it actually added. `content.ts` registers
that with `ctx.onInvalidated`.

Also scopes the redesign's previous-chapter selector to `a[href*='/chapter/']`,
mirroring legacy. The bare `a:has(> i.fa-arrow-left)` matched any left-arrow
anchor — a breadcrumb or back-to-fiction link would win on document order and
send the recap fetch to the wrong page.

Minor: trim cookie values before comparing in `liveCookieEquals`, drop a
redundant null check in `findBlurb`, and note that `resolveAdapter` keys off a
chapter-only sentinel.

* fix: restore the in-page report link on both layouts

The UI-adapter split dropped ReportLink.svelte, its test, and the
`reportPlacement` selector, removing the in-page "Report Broken Recap" shortcut
shipped in #125/#126 — from the legacy UI as well as the redesign. Only the
popup's report button survived. This restores it as a first-class mount point
rather than the ad-hoc querySelector it used to be.

Legacy keeps its sidebar placement. The redesign inserts the link directly after
RoyalRoad's own /report/chapter/ anchor, and both styling decisions there were
verified against the live beta:

- No `whitespace-nowrap`. That action column is `md:w-auto`, i.e. shrink-to-fit,
  so a label wider than RoyalRoad's own buttons widened the entire column
  (184px -> 208px), which stretched Fiction Page and made Report Chapter look
  shrunken.
- Colours come from RoyalRoad's `--color-secondary`/`--color-on-secondary` rather
  than `color: inherit`. That column inherits `color: black` against a near-black
  dark-theme background, so the link was effectively invisible. Custom properties
  also survive RoyalRoad's Tailwind purge, which its colour utilities may not.
- `grid-column: 1 / -1` makes it span the full row in the mobile two-up grid and
  is inert once the column becomes a flex column at `md`.

Labels are shortened to "Report Recap"/"Report Blurb" so they fit on one line in
the redesign's ~184px column.

* refactor: pin settings migrations to frozen schema snapshots

Migrations read live values from config/defaults, so today's defaults defined how
old data migrated. That wasn't hypothetical: because the current defaults already
contain `betaCookie`, `migrateV2toV3` emitted it too, which tripped
`migrateV3toV4`'s "already migrated" guard — so the v4 migration never actually
ran for anyone upgrading from v2.

Each step now carries its own frozen `as const` snapshot of the schema it
migrates from, and imports nothing from defaults. `V3_DEFAULTS` deliberately
omits `betaCookie` so the v3->v4 step is reachable. Adds a v2->v3->v4 chain test
covering that, plus one asserting a customised `reportPlacement` survives as a
legacy override.

* fix: make beta-cookie sync race-free and drop the unused clear helper

`syncFromStorage()` ran fire-and-forget while `watchSettings` registered right
after it, so a settings change landing in that window set the dedupe key to the
new value only for the in-flight startup read to overwrite it with the stale one
— after which reverting to that value was skipped as a false no-op.
`syncBetaCookie` now owns the key exclusively, so ordering stops mattering. It
also records the key only after a successful apply, so a sync skipped for missing
permission retries once granted.

`clearBetaCookie` was dead: nothing outside its own test called it, and there is
deliberately no "let RoyalRoad decide" mode, since removing the cookie doesn't
reliably revert to legacy. Removed it and `applyBetaCookie` from the public
surface; the latter is internal to `applyLayoutCookie`, which the tests now
exercise instead.

* fix(settings): show the layout hint only after a change, and trust exact cookie values

The "Save, then reload a RoyalRoad page to apply" note rendered unconditionally,
so it sat under the layout dropdown permanently as clutter even when nothing had
changed. It's now gated on the selection actually differing from what the view
opened with — the baseline is captured after the live-cookie seeding, so seeding
itself never counts as a change.

Seeding also mapped any unrecognised cookie value to "Classic", which can be
flatly wrong if RoyalRoad changes its values. It now only adopts a value that
exactly matches the configured beta or classic string, and otherwise leaves the
stored choice alone.

* refactor: derive settings validity instead of pushing it up through an effect

BasicSettings computed word-count validity, then reported it to its parents via
an `onValidationChange` callback fired from an `$effect`, which each parent
assigned to a `$state` flag gating the Save button. Validity is a pure function
of the settings object, so routing it through an effect made the flag lag a
render behind and left it stale whenever BasicSettings unmounted — in the popup
it stays mounted only in the settings view, while `isValid` is also read by the
advanced view.

The bounds and the validator now live in `lib/config/validation.ts`, and each
side `$derived`s from it independently: the form for its inline error, the
parents for the Save gate. The callback prop is gone. The number input's min/max
now reference the same constants the validator enforces, so they can't drift.

* refactor: address Svelte LSP findings across the components

Sweep prompted by the Svelte language server, which hadn't been run over these
files in a while.

Subscriptions: the `watchSettings` effects read the very state their callback
assigned, so every incoming settings change invalidated the effect and tore down
and rebuilt the subscription. They now read no state and subscribe once for the
component's lifetime, which also makes the `untrack` calls around them
unnecessary.

`bind:this` + `onMount` replaced with attachments, per the LSP's suggestion:

- SettingsButton attaches its host-footer layout to the legacy button itself, so
  the redesign branch structurally cannot restyle the host dialog and the version
  guard disappears. The attachment also restores the footer's original inline
  styles on teardown, which the onMount version never did.
- ToggleButton keeps its native, non-delegated click listener — that is
  load-bearing, since Svelte delegates `onclick` to the app root and a delegated
  `stopPropagation` runs too late to stop RoyalRoad's `.rr-dialog` from opening —
  but no longer needs a separate element ref to blur.
- PatchNotes dropped its map of article refs entirely; the card to scroll is the
  trigger's own `<article>` ancestor, reachable with `closest`.

Also removes an unused GearIcon import in AdvancedSettingsView, and documents why
RecapContainer's `{@html}` is safe (DOMPurify via HtmlSanitizer, strict
allowlist) so the warning isn't re-triaged next time.

* feat: adapt to whichever RoyalRoad layout is served

RoyalRoad is rolling out a Tailwind-based redesign, and readers can be on either
UI. The extension now detects which one was served, via the redesign-only
`#chapterHeroData` element, and mounts the matching adapter per page load — so
the recap button works throughout the rollout without the reader configuring
anything.

Detection deliberately ignores the `beta-ui-v2` cookie. The sentinel reflects
what RoyalRoad actually rendered, whereas a stale `always` could force the
redesign adapter onto a page served as legacy. Verified live against RoyalRoad's
own layout toggle in both directions.

This drops an earlier approach that let the extension *force* a layout by writing
that cookie. It needed the optional `cookies` permission, which turns out to be
effectively ungrantable on Firefox for Android: `permissions.request()` resolves
false with no prompt, a temporarily-installed add-on exposes no permission UI to
grant it by hand, and `permissions.contains()` reports the permission absent even
when it is held. The switch was therefore inert on mobile while still costing a
permission and a settings control that looked functional — and RoyalRoad ships
its own layout toggle regardless.

Removed: beta-cookie.ts and its tests, the `betaCookie` setting, the layout
select in BasicSettings, the cookie section in AdvancedSettings, the background
sync, and `optional_permissions: ["cookies"]`. The manifest now requests nothing
beyond storage and the royalroad.com host.

migrateV4toV5 drops the stored `betaCookie`. v3→v4 stays in place above it, since
settings still at v2 or v3 must pass through it to get here.

* chore: release notes for v1.8.0

* test(migrations): pin the v2 fixture to its own frozen snapshot

The scenarios spread the legacy adapter's live LEGACY_SELECTORS as v2-era
input, while the code under test compares against migrations.ts's own frozen
V2_LEGACY_SELECTORS. They match today, so the tests passed — but editing any
legacy selector would have silently reclassified these "user customised
nothing" inputs as "user customised everything", and the resulting failures
would have pointed at the migration rather than at the edit.

Same reasoning that made the migrations themselves hermetic in a0756e8: a
fixture describing schema v2 must not track what the adapters look like now.

* refactor(adapters): make RoyalRoad layouts pluggable via a registry

Supporting two layouts during the redesign rollout is temporary — one of them
will die — but removing either meant touching 11 files, because the layout
names were spread across the codebase: hand-written UiVersion union, two
by-version maps, hardcoded `{ legacy: {}, redesign: {} }` literals in three
places, a `version === "redesign"` branch in a component, and defaults.ts
importing the redesign adapter while the legacy adapter imported back from
defaults.ts.

registry.ts is now the only module in src/ that names a layout. ADAPTERS is
tried in order with the last entry as the unconditional fallback, and
UiVersion, the by-layout default maps, emptyOverrides(), resolveAdapter() and
the Advanced Settings picker all derive from it.

Supporting moves:
- detect(doc) and label move onto UiAdapter, retiring isRedesign(); each
  adapter now owns its own sentinel.
- LEGACY_SELECTORS/LEGACY_CHROME move into legacy-adapter.ts, breaking the
  defaults.ts <-> adapter import cycle and making the two adapters symmetric.
- selectorOverrides is keyed by `string` rather than the UiVersion union, so
  adding or dropping a layout needs no storage migration; orphaned overrides
  sit unread until restoreSelectors() prunes them.
- HostClasses becomes HostChrome and carries the styles, icons and labels that
  differ per layout, so SettingsButton no longer branches on a layout name.
  Its legacy modal-footer restyling moves into LegacyAdapter.prepareMounts,
  where MountSet.cleanup already reverts host-page mutations on teardown.

Both rip-out directions were performed and typechecked rather than assumed.
Deleting the legacy adapter surfaced a latent break: detect() declared with no
parameter typechecks while a union of adapters exists, but fails the moment
it is the only adapter left. It now takes an unused `_doc`, with a comment.

migrations.ts keeps spelling layout keys out literally — migrations describe a
schema as it stood at a point in time and must not track the live registry.

Drops REDESIGN.md; the adapter guide is kept outside the repo.
PR #131 landed on main as a squash commit, so git lost the ancestry link
between the two branches and every dev->main merge re-applied changes main
already had. This merge records main as an ancestor without changing any
files (dev is already a content superset of main).
Takes `pnpm audit` from 82 advisories (1 critical, 34 high) to 1, and
`pnpm audit --prod` to zero.

Only dompurify ships in the extension bundle; everything else here is dev
or build tooling. dompurify 3.3.1 -> 3.4.12 clears all 17 production
advisories.

Remaining transitive advisories come in via build tooling (mostly
wxt -> web-ext-run) whose parents have not bumped them yet, so they are
pinned through pnpm overrides. The one left is yauzl, which has no
patched version published.

Two things needed handling beyond version bumps:

- Test DOM moved from happy-dom to jsdom. DOMPurify >=3.4 mis-walks the
  DOM under happy-dom and removes the wrong nodes, so <script> survived
  sanitization while <p> was stripped, which silently defeated the
  sanitizer tests. The same input sanitizes correctly under jsdom and in
  real Chromium, so the extension itself was never affected. jsdom does
  not implement scrollIntoView, so it is stubbed in the Vitest setup.

- @sveltejs/vite-plugin-svelte held at 6.x. The 7.x line peers vite ^8
  while wxt and vitest resolve vite 7, which installed two copies of vite
  and broke `pnpm check` on mismatched Plugin types. Moving forward
  instead would require vitest 4, whose worker start timeout is hardcoded
  and too short for this repo on WSL, making the suite flake.

Verified: 87 unit tests, svelte-check, web-ext lint, prettier, and both
Chrome and Firefox builds all pass, including a frozen-lockfile install.
@Seismix
Seismix merged commit 26b51c4 into main Jul 23, 2026
5 checks passed
Seismix added a commit that referenced this pull request Jul 23, 2026
Release PR #138 landed on main as a squash commit (the repo enforces
linear history, so squash is the only merge method available). That
breaks the ancestry link between the branches, so without this the next
dev -> main merge would re-apply changes main already has and conflict on
every touched file.

Records main as an ancestor without changing any files.
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.

Scrolling before recap loaded is jarring

1 participant