npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@abraca/junis

v2.102.0

Published

Website-builder Nuxt module for the Abracadabra CRDT platform — page-type renderers, presence chrome, studio editing and SSR, all driven by documents

Readme

@abraca/junis

A website builder for Abracadabra. Point it at a document tree and you get a website: typed page renderers, presence-aware chrome, an owner editing studio, server-side rendering, feeds, search, and visitor interaction — all driven by documents rather than code.

Status: pre-1.0, and honest about it. All 23 page types render and are exercised on every boot from an empty database — the playground ships no database, deliberately, because one that ships a database never proves its seeder works. A cold start seeds 98 documents, 34 bodies and two generated photographs for the wall.

Verified on every change, and these numbers are what the commands print:

| | | |---|---| | pnpm parity | 619 SSR assertions across all 23 types | | pnpm parity:nav | 56 assertions — the only check that catches broken client-side navigation | | pnpm test | 150 assertions, no server needed | | pnpm lint · pnpm typecheck | clean |

The globe is on in the playground with a token and seeded waypoints, and off by default in the module because enabling it pulls mapbox-gl into the bundle graph.

The largest gap: parity and parity:nav both walk the site as a visitor. Nothing automated exercises the owner studio. Remaining gaps are in FOLLOW-UP.md; CLAUDE.md is the working reference.

pnpm add @abraca/junis @abraca/nuxt @abraca/dabra @abraca/convert

Three shapes, and you have to pick one

jun: { access: { mode: 'public' } }   // 'open' | 'public' | 'private'

| | Reads | Writes | Runnable example | |---|---|---|---| | open | everyone | everyone | playground/ · jun.is | | public (default) | everyone | the owner, plus guestbook/poll/thread over RPC | examples/atelier | | private | members only | members, per their grant | examples/kammer |

private is not a login screen bolted on the front. Everything public about a jun-is site is served from a doc cache that a service-role runner fills, so without a gate a "private" deployment refuses a stranger's CRDT socket and then hands them the whole tree over its own endpoints. mode: 'private' closes that: /api/** answers 401, a navigation renders a sign-in screen with no document payload, and the feeds, the sitemap and the link-preview cards are gone.

Every shape has a server half too — the backend is the authority, and nothing in this module grants or withholds access on its own. ACCESS.md has both halves for all three, plus the owner's new Access settings panel (⌘, → Access): who holds a grant here, who may join, and whether this page is public.

All four ship from the same release train and carry the same version. The three @abraca/* siblings are peers, not dependencies, so your app resolves one copy of each — a second copy of @abraca/convert means a second yjs identity, and Y content built against one cannot be inserted into a document owned by the other.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@abraca/junis'],

  jun: {
    brand: { name: 'Acme', tagline: 'We make things' },
  },

  abracadabra: {
    url: 'https://your-backend.example.com',
    entryDocId: '…',
  },
})
/* app/assets/css/main.css */
@import "tailwindcss";
@import "@nuxt/ui";
@import "@abraca/junis/css";

That is a working website. The module installs what it builds on (@abraca/nuxt, @nuxt/ui, @nuxt/fonts, @nuxt/image, @vueuse/motion) unless you have listed them yourself.


⚠️ Registration must stay open

The single most important thing about deploying anything built on this module.

A visitor is not an anonymous session: the SDK mints a guest Ed25519 keypair in the browser and registers it. That soft identity is a real account, so the visitor is authenticated and should land on [access].authenticated = viewer — read and awareness, no write.

Observer, the only role an anonymous principal may hold, is excluded from the server's can_send_awareness(). An anonymous visitor therefore cannot carry presence, which is the premise of the entire system. Closing registration silently pushes every visitor onto that path, and nothing will look broken — presence simply stops.

Because visitors cannot write, everything they contribute (guestbook signatures, reactions) goes through the RPC runner, whose handlers receive the server-stamped caller id.


Page types

Each key registers a jun-<key> type and mounts its renderer. All are on by default except globe.

| Type | What it is | |---|---| | jun-journal → jun-entry | A writing feed and its articles. Drafts, scheduled publishing, covers. | | jun-live → jun-now | A social timeline and its posts. The composer is a draft document. | | jun-apps → jun-app | A reorderable icon grid and its project pages. | | jun-links | A column of link rows. A row with no URL opens its own page. | | jun-cv | A career spine, newest first. | | jun-uses | A grouped list; the sections come from the page's own meta. | | jun-library | A reading list shelved by status, with ratings. | | jun-wall | A masonry photo wall. Photos are real documents with EXIF extracted. | | jun-cinema → jun-movie | A poster shelf and its theatre. Uploaded film (streamed) or a provider link (facade). | | jun-festival → jun-track | A music library shaped like a music app, with a player that survives navigation. | | jun-services | Live figures from keyless public APIs, fetched and cached server-side. | | jun-mosaic | A page composed of other pages — panes are documents, rendered by their own renderers. | | jun-guestbook | The one page visitors write to, over RPC. | | jun-stats | Live numbers over the document tree. | | jun-redirect | A document whose meta.url is a destination. Emits a real 301. | | jun-home | A composed front page — sections assembled from the tree. | | jun-config | The site's own settings. Owner-only, enforced. | | jun-globe | A map of a journey with a route editor. Off by default — see below. |

jun: {
  pageTypes: {
    globe: true,                          // opt in
    guestbook: false,                     // never registered
    journal: { label: 'Notes', icon: 'i-lucide-notebook' },
  },
}

false means the renderer chunk is never referenced, so a disabled type's dependencies never enter your bundle graph. That is why this is a build-time option and not something the settings document carries.

The home page

/ renders the space root document through the ordinary pipeline, which is usually right. Setting that document's type to jun-home — from its own context menu — opts into JunHomeRenderer instead: a front page assembled from sections that resolve themselves out of the document tree.

| | | |---|---| | Layouts | stack · split · magazine · minimal | | Sections | hero · latest writing · projects · now · links · pages · page content |

Both live in the document's meta (homeLayout, homeSections), edited from a bar on the page itself. Nothing in it knows a slug or a type id: a site that renames its journal, remaps a type or turns projects off keeps a working home page, and the section that has nothing to show drops out heading and all.

Adopting an existing corpus? pageTypeAliases maps a legacy type id onto a canonical one, so documents keep rendering without a migration:

jun: { pageTypeAliases: { 'janis-journal': 'jun-journal' } }

It is also how you survive a type being retired. jun-window — a pretend application that showed the whole presence vocabulary at once — was removed in 2.82.0. A document still carrying that type does not break: an unrecognised type falls through to FLOWING and renders in the editor, which is the same tolerance that makes "Page type ▸" a safe menu item. To point those pages somewhere deliberate instead:

jun: { pageTypeAliases: { 'jun-window': 'jun-mosaic' } }

The mosaic — an alternative to the home page

jun-home composes a front page out of a fixed vocabulary of sections. jun-mosaic has no vocabulary: its children are the panes, and a pane renders a document through <ADocRenderer> — the same call a whole page makes. So a mosaic can hold a live journal feed beside a globe beside a kanban, and none of it is a special case.

| | | |---|---| | Layouts | bento · columns · masonry · feature · freeform | | A pane | meta.paneDoc (render that document) · nothing (render itself) · meta.paneWidget (clock, presence, brand) | | Sizing | meta.paneSpan { c, r } in grid units; meta.paneRect { x, y, w, h } in freeform |

Drag a tile to rearrange, drag its corner to resize, drag a page in from the navigation to add it — every one of those is a CRDT write, so the layout changes live for anyone else looking at it. Setting the ROOT document's type to jun-mosaic makes it the front page, the same opt-in jun-home uses.

Panes are never editable and (by default) not interactive: a tile is a window onto a document, and clicking it opens that document. meta.paneInteractive opts a single pane back in. Live panes mount lazily and are capped (jun.mosaic.maxPanes, default 24); a pane past the cap, or one that would nest the page inside itself, renders as a card rather than disappearing.

Media: what leaves the browser, and when

jun-cinema and jun-festival both take uploads or links, and the two behave very differently on purpose.

  • Uploads stream. JunMedia mints a short-lived grant and hands the browser a server URL, so a film seeks over HTTP Range instead of downloading whole. Raise [uploads] max_file_size on your backend — the 10 MiB default rejects a phone video.
  • Links show a facade. A YouTube or Vimeo URL renders a poster and a play button; the third-party iframe is constructed only after a click, against youtube-nocookie.com. Nothing is requested from a provider until a visitor asks for it. features.thirdPartyEmbeds: false degrades every provider link to a link card, and jun.embeds.remoteThumbnails (off) governs whether even the poster may come from the provider.
  • An unrecognised host never becomes an iframe. It renders as a link.

Signals fetch from the server, never the browser

jun-services cards are fetched by the module's own cached endpoint. Every adapter is keyless — github-repo, github-user, npm, crates, rss, http, og — and adding a card is pasting a URL, which is sniffed into a provider and a reference.

Three of them (rss, http, og) take a URL from a document, which makes the endpoint a request forwarder. It is guarded: http(s) only, DNS resolved and checked against private/loopback/link-local ranges at every redirect hop, a five-second timeout, a one-megabyte body cap, and nothing about the visitor forwarded. A site that wants no such surface drops those three:

jun: { services: { adapters: ['github-repo', 'npm'] } }

jun.githubToken is optional and server-only; without it GitHub allows 60 requests an hour per server IP, which the cache normally absorbs.

The globe needs a Mapbox token

It is the module's only external-service dependency, and the only place a site must configure something beyond its own content:

jun: {
  pageTypes: { globe: true },
  mapboxToken: process.env.MAPBOX_TOKEN,
}

It falls back to whatever @abraca/nuxt is already configured with, so a site that already shows maps needs nothing new.


Options

brand

Site identity. The only thing most sites set.

| | | |---|---| | name | <title> suffix, JSON-LD, footer, studio bar | | tagline | Meta description default, hero subtitle fallback | | seoDescription | Overrides tagline for the meta description | | avatar | Author avatar on timeline posts. Falls back to an icon. | | logoMark | Which built-in mark <JunLogo> draws. Default 'sun'. | | logoVariant | 'mark' (default) | 'lockup' | 'wordmark' | | themeColor | PWA / browser theme colour | | icons | PWA manifest icons. No defaults — see PWA below. | | schemaType | 'Person' | 'Organization' | false |

name defaults to Jun!s. The bang is part of the name rather than punctuation, and <JunLogo> gives it its own span so a lockup can weight it — a name without one renders as plain text, so this costs nothing to override.

logoMark and logoVariant choose between the marks <JunLogo> already draws; they are not a way to supply a logo of your own. It ships four, each the bang in a different register — sun (the orb, and the one mark that carries a colour; the default), sprout (a lowercase j that is also a tree, and upside down the bang), node (the same silhouette as a graph: three points, two edges) and page (the document glyph with the bang set inside it) — plus three variants (mark, lockup, wordmark), all pickable live from the settings document. To supply a logo of your OWN, declare your own JunLogo.vue in app/components/; Nuxt's name resolution prefers it everywhere the mark appears.

The sun takes the site's accent. It is the one mark with chroma, and the chroma is themeAccent's: the theme sheet emits --jun-sun-from / -to / -core / -glow per colour scheme, so the orb follows an accent change live and needs no colour-mode read of its own. Its core is picked by the body's BRIGHTNESS rather than by the scheme — white on a pale orb is a featureless disc, and a pale accent has that problem in daylight. A monochrome site gets the monochrome orb: near-black with a white core, inverted on dark.

Hover, press and the intro's draw are CSS, in jun.css rather than in the component — they have to answer an ancestor's :hover (the navbar's home link, the settings picker's button), which a scoped style cannot reach.

Whatever you use must render identical markup in the navbar and the intro — the first-load sequence FLIP-morphs one onto the other, and the handoff is only invisible if they match. That is why the mark is chosen inside the component rather than passed in per call site.

features

Each flag gates both halves of its feature — the client mount and the server routes or runners it needs — so switching one off genuinely removes it.

| Flag | Default | | |---|---|---| | intro | true | First-load logo choreography | | backdrop | 'blobs' | The ambient field — see Backdrops below | | presence | true | The base every other presence feature reads | | awareBlobs | true | Peers as soft tinted blobs | | peerCursors | false | Named site-wide cursors — reads as surveillance | | travellersChip | false | A "who's online" widget, which this is not | | campfire | false | The user center — see Campfire below | | studio | true | Owner edit mode | | search | true | ⌘K overlay + full-text endpoint | | feeds | true | /feed.xml + /feed.json | | sitemap | true | /sitemap.xml | | rpc | true | Visitor writes: guestbook, reactions, scheduled publishing | | docCache | true | The SSR runner. Without it pages render empty. | | bootstrap | true | Seed a starter tree into an empty backend | | slideover | true | The document peek panel — powers "Open in slideover" | | contextMenu | true | Document context menus. Off leaves the browser's own. | | lightbox · announcement · footer | true | |

campfire

The site's user center: a hearth in one corner that opens a small tabbed panel — who else is here, one shared fire to talk around, and each visitor's own handle. features.campfire decides whether a fresh site boots with it lit; every key below is also a Site Config key, so the owner changes any of them live from the settings page without a redeploy.

| | Default | | |---|---|---| | title | 'Campfire' | What the place is called — header, empty state, every aria label | | icon | 'i-lucide-flame' | The mark on the hearth, its panel and its chat tab | | chat | true | The shared fire. See the note below — this one has a server half. | | roster | true | Who is here, and where | | identity | true | The visitor's own handle and mood | | reactions | true | Emoji embers, rising whether the panel is open or not | | follow | true | Tap a traveller to walk the site with them | | showPages | true | Show what each person is reading. Off is the private choice. | | showBots | false | Include runners and agents. Off by default — see below. | | allowRename | true | Let a visitor choose their own handle | | reactionSet | ['🔥','❤️','👋','☕'] | The palette, in order | | historyLimit | 60 | Messages kept on screen (5–300) | | placement | 'bottom-right' | Any of the four corners. The top two clear the navbar. | | size | 'md' | sm · md · lg | | pillMode | 'full' | full · compact · count · ember — how loud the closed hearth is | | rosterStyle | 'list' | list · grid · facepile · dots | | openTab | 'auto' | Which panel a tap opens | | blur · ember | true | The frosted panel; the warm flame (off keeps it grey) | | label | '' | Replaces "N others warming here". {n} is the count. |

chat has a server half, and it is the reason chat works at all. Visitors are soft identities pinned to viewer and the server gates messages:send on write access to the CHANNEL document — so a campfire bound to the space root has every visitor message silently rejected behind the client's own optimistic echo. The jun:campfire runner (in jun-rpc) therefore opens exactly ONE document with public_access = "editor": a hidden kind: "channel" child of the space root. Turning chat off clears that access again. This needs features.rpc; without the runner there is no channel, and the panel says so rather than offering a composer that loses what is typed into it.

showBots is off because a site with a doc-cache runner would otherwise greet its first human visitor with "3 others warming here", none of whom are people. A session that never broadcasts a page is a process, not a reader; the janitor deliberately broadcasts one, so it counts as a person.

Renaming it renames the metaphor too. "Campfire" is this module's word, not every site's — a support desk, a common room, a lobby. Setting title to anything else also drops the two strings that only make sense around a fire: the chat tab becomes "Chat" rather than "Fire", and the no-channel state says "no channel yet" rather than "the fire is not lit yet". Pick a icon to match; the settings page offers the full Lucide set through the same picker the document tree uses, and an unrecognised value falls back rather than rendering an empty box.

The owner can change a person's role from the roster. In the list style, each row carries a menu for the site owner: Owner / Editor / Viewer / Observer, "Remove access", and the person's public key. It writes a permission row on the SPACE ROOT, so it cascades exactly as the campfire channel's own access does, and it reads the permissions back afterwards — the chip beside a name is what the SERVER stores, never what was asked for. admin and service are not offered: the server refuses to grant an elevated role to anyone but a root admin, so a button for it would be a button that fails.

The grant is keyed on the public key awareness carries, which is the DEVICE key. For a soft identity — every ordinary visitor — that key IS the account. For someone who signed in with a password on a device with its own keypair the two differ, and no endpoint resolves one to the other from the browser; the read-back is what makes that visible rather than silent. The other three roster styles get no menu at all: they are ambient displays, and hanging a privilege change off a 10px dot is how someone grants ownership by accident.

contextMenu

Every document surface — nav items, list rows, grid tiles, cards — carries the same menu, built by useJunDocMenu(). Visitors get the read-only half (open, open in a new tab, open in the slideover, copy link, share, subscribe); the owner vocabulary (rename, icon, page type, duplicate, pin, hide, move, trash) is added while editing.

Each item is individually switchable, because a menu is site voice rather than plumbing:

| | Default | | |---|---|---| | enabled | true | Master switch. Off leaves the browser's menu everywhere. | | visitor | true | The read-only half. Off returns to owner-only. | | open · newTab · slideover · copyLink · share · subscribe | true | | | copyMarkdown · copyTitle · copyId | false | Opt-in — clutter on a small site | | duplicate · changeType · setIcon · pin · hide · moveToTop · trash | true | Owner items | | suppressNative | false | Suppress the browser menu on page body for visitors |

suppressNative defaults to off on purpose. The reference site suppressed it unconditionally, which also took away "Copy" on every paragraph of prose.

Contribute your own items from a client plugin:

registerJunMenuItems((ctx) => ctx.type === 'jun-app'
  ? [[{ label: 'Open repository', icon: 'i-lucide-github', onSelect: () => …ically }]]
  : null)

navigation

Build-time defaults for the navbar. Every one is also a Site Config key, so these are what a fresh site boots with rather than a lock.

| | Default | | |---|---|---| | placement | 'top' | top | top-bar | bottom | rail-left | rail-right | | shape | 'pill' | pill | rounded | square | ghost | | size | 'md' | sm | md | lg | | itemMode | 'text' | text | icon | icon-focus | icon-text | icon-stack | | activeStyle | 'underline' | underline | pill | dot | bold | glow | box | bracket | | autoHide · blur · showLogo · centerActive | true | | | showIndices · keyboardNav · freshnessDots | true | | | homeItem | false | An explicit Home item | | travellers | false | The fellow-travellers dot stack in the right cluster | | travellersList | false | Hovering that stack names each traveller and their page |

Every placement floats over the content rather than displacing it, which is what keeps it a presentation choice: no renderer knows which one is active and switching one live never reflows a page. The rails fall back to top below lg, and a PANEL page type still docks the bar full-width regardless.

Both traveller switches are off, and they are two switches for a reason. The dots say how MANY people are here; the roster says WHO they are and what each is reading. A site may reasonably want the first without the second, and off is the more private default for both — in a navbar whose whole idea is slight ambient presence, a dot stack with a hover roster reads as a "who's online" widget, and the campfire is where a real user center belongs. With travellersList off the chip renders as a plain readout with no popover at all, rather than a button that opens nothing. features.travellersChip still decides whether the component is in the build; these decide whether it is shown.

Surface — the performance lever

themeSurface on the settings document: glass (default) · soft · solid.

backdrop-filter is the most expensive property on the page, and this site has one on the navbar, every card, the hearth, the lightbox, the timeline and several renderers. Each re-reads whatever is behind it, so a background image multiplies the cost across all of them at once — which is exactly when a site starts to feel sluggish.

| | Frosted surfaces | | |---|---|---| | glass | 15 | The house look | | soft | 4 | Navbar and hearth only; content surfaces go flat | | solid | 0 | No blur anywhere, panels opaque |

(Counts measured on the settings page — the heaviest one.)

Removal is universal (backdrop-filter: none on *), restoration is a short list by name. That asymmetry is deliberate: setting a value on * would give every element its own compositing layer, which would be far worse than the problem it solves.

Backdrops

features.backdrop picks the ambient field:

blobs · dots · grid · aurora · mesh · rings · waves · noise · solid · shader · false

All of them except shader are pure CSS — no canvas, no filter: blur(), no per-frame JavaScript beyond a pointer parallax that stops once it settles — so the owner can switch between them live from the settings page. shader pulls an optional WebGL peer and must be chosen at build time; when it is, the runtime picker is disabled and says why.

backdrops narrows the set the settings page offers.

A background IMAGE composes with the backdrop rather than replacing it. Drop a photograph or a texture on the settings page and it becomes the page's base, with the chosen field still drifting across it — cover / contain / tile, a strength from 5% to 100%, and an optional softening. It is stored as an upload reference (<docId>/<uploadId>, attached to the space root) or as a literal URL, and served off the thumbnail ladder rather than as the original: a background sits behind content, usually below full strength, and the difference is a multi-megabyte decode against a hundred kilobytes.

Softening is a low-resolution source, never filter: blur(). A blurred full-viewport layer is a Gaussian the compositor re-reads for the layer and again for every backdrop-filter surface above it — which on this site is the navbar, every card and the hearth. A 64px copy scaled up to the viewport is the same picture and costs nothing: upscaling is a blur. (It is disabled for tile, where the image's natural size is the repeat size.)

The image is painted INSIDE the backdrop component rather than on a layer beneath it, and that is load-bearing: both backdrops own an opaque base colour because mix-blend-mode blends only within its own stacking context, and five of the nine kinds multiply or screen against it. A separate -z-20 layer with a transparent backdrop above it would have silently broken aurora, mesh, rings, waves and noise. Here the pattern blends against the photograph, which is what "they work together" has to mean to be worth having.

Dark mode. Every kind reads far more strongly on a dark scheme than a naive port of its light values would: a pattern screening onto a near-black base at the alpha that reads as a quiet texture on white lands within a couple of RGB steps of the background and is, in practice, invisible. The dark palettes are brighter and the dark opacities higher — both halves were needed, and the blob field takes its two inner alphas from CSS variables so the scheme can raise them without the gradient builder knowing which scheme it is in.

Everything else

| Option | | |---|---| | layout | Which types are immersive / panel / feed / self-padded | | nodePanel | 'navigate' (default) or 'slideover' — what a plain click does | | palettes · paletteMap | Backdrop palettes, keyed by TYPE so a rename keeps them | | seed | The starter tree. 'default' seeds one page per enabled type. | | guestNames | Vocabulary for warm anonymous handles | | metaExcludeKeys | Keys that must never auto-materialise as an editor chip | | siteConfigDefaults | Starting values for the settings document | | imageDomains | Remote hosts <NuxtImg> may optimise | | apiBase | Default /api/_jun | | pages · layoutName · fonts · autoInstall | Opt out of what the module registers |


PWA

A manifest is contributed only if you have installed @vite-pwa/nuxt yourself. A service worker has real consequences — stale assets, update prompts — and that is not a decision a content module should make for you.

It is built from brand, so your manifest and your site cannot drift apart. No default icons are supplied: pointing a manifest at files you do not have produces a broken install prompt rather than none.

Navigations stay network-first with no offline fallback. This is a realtime SSR site and the CRDT layer owns offline data; a worker serving stale HTML would fight it.


How it renders

Documents are server-rendered from a Nitro doc cache, then become realtime on the client. Skeletons should rarely be seen.

The seeder writes real document bodies into a fresh backend and hands them straight to that cache, so a cold deploy serves prose on its first request rather than empty pages for the first two minutes.

Page-type renderers are client-only by design, which means for a crawler, a feed reader or a link preview the SSR payload is the site. pnpm parity asserts that half: every sitemap URL returns 200 with its own title and description, a document with a body ships that body, no page paints a skeleton, JSON-LD appears where a type declares it, feeds carry real content, search reaches body text, and an unknown URL is a real 404.


Commands

pnpm dev            # backend + Nuxt
pnpm dev:clean      # reset the playground database and caches
pnpm parity         # SSR assertions against a running dev server
pnpm parity:nav     # clicks through the nav — catches broken client routing
pnpm lint
pnpm typecheck

Run both parity scripts. They are blind to different things: parity reloads for every check, so it cannot see a broken in-app navigation, and parity:nav never reloads, so it cannot see an SSR regression. A bug that survived twelve phases here was invisible to the first and obvious to the second.

pnpm dev:clean prints an IndexedDB incantation to run in any open tab. That is not optional: a tab still open on the site will re-push its copy of the old tree on reconnect, and you end up with two of every page. Repeat the cycle and the tree multiplies. A deleted database is not a tombstone — it is a peer that lost its memory, and the browser holds the newer state.

Requirements

Node ≥ 22 · @abraca/nuxt ≥ 2.74.0 · Nuxt 4

yjs, y-protocols, @tiptap/*, prosemirror-* and vue must each resolve to one copy. Two yjs copies break every instanceof check, which empties the SSR cache silently. The module dedupes what it can; a workspace with linked packages may need pnpm.overrides.

Licence

MIT