@skyelight/build
v0.13.3
Published
Stamps each element with the source file that wrote it, so feedback left on a deploy points an agent at the right code
Maintainers
Readme
@skyelight/build
Two things, both about feedback left on a deploy.
It stamps each element with the source file that wrote it, so a pin points an agent at the right code. And it runs the widget, so people who cannot install the browser extension can leave that feedback at all.
Without the stamps, an agent handed a pin gets a URL and a CSS selector and has
to grep for the element. With them, the pin says
components/PricingCard.tsx:88 — and which commit and branch were on the
screen at the time.
Pairs with @skyelight/mcp,
which is what reads the stamps back out to an agent.
Setup, all of it
npx skyelight-build init # npm
pnpm dlx skyelight-build init # pnpm
yarn dlx skyelight-build init # yarn
bunx skyelight-build init # bunThat is the whole of the code side. It installs the package, finds your config and shows you the diff before writing anything — and the plugin writes the widget's script tag into your pages at build. Nothing to paste, nothing to import, nothing in your source.
It handles Next, Vite, Astro and WXT, merges into a plugins array or a vite
key you already have rather than replacing it, and declines anything it does
not recognise, printing the two manual lines instead of guessing. The package
manager comes from packageManager in your package.json if you have one,
otherwise from your lockfile; a project with more than one lockfile is asked
rather than guessed at, and --pm pnpm answers it.
TypeScript declarations ship with the package, and withSkyelight returns your
own config's type rather than widening it.
Then add your site's URL to the project in Skyelight — your project's Settings, in the Skyelight Build Plugin card.
That is the one step that is not in your code, and it cannot be automated: until a site is on that list nobody can sign in through it, and that list is the whole security model. The URL you add is also what tells us which project the feedback belongs to, so there is no project id to configure and keep in step with us.
No keys, no backend URL, no project id.
By hand
init is the easy road. If you would rather:
npm install -D @skyelight/buildNext — wrap the export you already have:
// next.config.ts
import { withSkyelight } from "@skyelight/build/next";
export default withSkyelight(nextConfig);Vite, Remix — add to the plugins array you already have:
// vite.config.ts
import { skyelight } from "@skyelight/build/vite";
export default defineConfig({
plugins: [react(), skyelight()],
});Astro and WXT put it under a vite key. If you already have one, add to
it — do not replace it:
// astro.config.mjs // wxt.config.ts
vite: {
plugins: [skyelight()];
}
vite: () => ({ plugins: [skyelight()] });The widget
What a visitor pays
The tag loads about 1.5kB. On a page nobody is reviewing, that file makes no request, adds nothing to the document and sets no timer — it reads one flag and stops. Mounting anything more on every page load would tell us that somebody opened your site, which would make us a third-party tracker on your pages.
Everything else — the badge, the pin markers, the composer — sits behind a dynamic import that only runs once somebody has signed in for this site.
| | | | --------------------- | ------------------------ | | Nobody has signed in | ~1.5kB, and nothing runs | | Somebody is reviewing | ~250kB, once |
Signing in
The first time, the widget sends the browser to Skyelight — which knows who you are — and comes straight back. A top-level redirect rather than a hidden iframe: an iframe is what Safari's tracking prevention blocks, and it is why an earlier version of this worked in Chrome and nowhere else.
After that the browser remembers for thirty days, for that site only. Removing somebody from the workspace cuts them off within minutes, and taking a site off the list cuts every session on it at once.
Screenshots
Every pin gets one, automatically, with nothing to click.
The picture is your DOM drawn again into a canvas rather than a photograph of the tab. A page cannot photograph its own tab, and the one API that comes close puts a share-your-screen dialog in front of somebody who only wanted to leave a comment.
It is usually very close. Where it is not, and worth knowing: a cross-origin
image comes out blank unless its host sends CORS headers, a web font can fall
back, and iframes, <canvas> contents and video render empty.
Where the widget runs
Preview and local development by default; production only when asked:
SKYELIGHT_WIDGET=1 # on, including production
SKYELIGHT_WIDGET=0 # off, including previewIts own switch, separate from SKYELIGHT_STAMP. Collecting feedback on a live
site and putting source paths in its HTML are different decisions, and one
variable for both means getting one of them by accident.
What it needs from your CSP
script-src https://app.skyelight.ai the widget
connect-src https://*.convex.cloud the pins themselves
https://app.skyelight.ai signing inWhat it stamps
Every host element in your JSX picks up a few attributes, and <html> picks up
the build:
<html data-sky-build="9f2a1c4" data-sky-branch="feat/checkout">
<li
data-sky-src="components/today/today-plan.tsx:90"
data-sky-component="DoseRow"
data-sky-id="9dbfcb76"
data-sky-key="{dose.id}"
></li>
</html>data-sky-src is where it was written. data-sky-component is what wrote it,
and it is the more durable of the two — a line number moves the moment anyone
adds an import above it, while a component name survives edits, reformatting
and the file being renamed.
The name comes from the enclosing function, following React's own rule that
capitalised is a component and lowercase is a helper. memo and forwardRef
wrappers are unwrapped. An element that belongs to no component — an icon
defined in a data map, say — is left unnamed rather than given a guess.
data-sky-id is the anchor: an identity for the element rather than a
location, derived from the file, the component, the tag, and which one of that
tag it is. A selector says where an element sits, so it breaks when a wrapper
appears or a sibling moves; this breaks only when the element is genuinely
restructured. data-sky-key carries the author's own React key on list
items, because one .map() stamps every row with the same id and the key is
what tells them apart.
Paths are relative to the repository root — git rev-parse --show-toplevel,
not the directory the build ran in. In a monorepo, building from apps/web
still stamps apps/web/app/page.tsx, so a stamp means the same thing to
anything holding the repository. Where there is no git (a Docker build from a
tarball, a shallow CI export) it falls back to the build directory.
When stamping runs
Preview builds only, by default. Paths in the HTML are readable by anyone who can load the page, and production is not where this feedback comes from.
| Environment | Stamps |
| ------------------------------------------ | ------ |
| VERCEL_ENV=preview | yes |
| VERCEL_ENV=production | no |
| Netlify, CONTEXT other than production | yes |
| Anywhere else, NODE_ENV not production | yes |
SKYELIGHT_STAMP=1 forces it on, SKYELIGHT_STAMP=0 forces it off, and either
beats the platform. Use 0 if your previews are public and your directory
names are not. A branch deploy needs no configuration at all — which is also
where the branch stamp is worth having.
The commit and the branch
Both come from the build environment, and both fall back to git locally:
| | read from |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| commit | SKYELIGHT_BUILD_ID, VERCEL_GIT_COMMIT_SHA, GITHUB_SHA, COMMIT_REF, CF_PAGES_COMMIT_SHA, then git rev-parse --short HEAD |
| branch | SKYELIGHT_BRANCH, VERCEL_GIT_COMMIT_REF, GITHUB_HEAD_REF, GITHUB_REF_NAME, BRANCH, CF_PAGES_BRANCH, then git rev-parse --abbrev-ref HEAD |
The commit is shortened to seven characters; the branch is not, because half a
branch name is not a branch name. On a GitHub pull request GITHUB_HEAD_REF
wins over GITHUB_REF_NAME, which there is the merge ref (482/merge) and
names no branch anyone works on. A tag build and a detached HEAD both stamp a
commit and no branch.
Neither is required. A build that cannot work either out stamps what it has and carries on — a plugin that fails a deploy because git was unavailable is worse than a pin with no commit on it.
Together they are what lets an agent ask whether the file has changed since the pin was left, which is often the answer.
The build id in local development
On a deployed build the commit is exact: the platform sets it, the build runs once, and the code and the stamp cannot drift apart.
A local dev server is different, and worth knowing about. The commit is read
from git and re-read as files are transformed, but the build id lands on
<html> — which lives in your root layout. Bundlers cache transformed modules,
so if you commit and then edit some unrelated component, the layout is not
re-processed and the page keeps the previous commit.
It corrects itself when the layout is re-transformed. Restarting the dev server is the reliable way; touching the layout file also does it.
The per-file source stamps are unaffected — those line numbers come from the source being transformed, not from git.
What it does not stamp
- Components.
<PricingCard />gets nothing — the attribute would be a prop, and most components drop unknown props. The elements insidePricingCardcarryPricingCard.tsx, which is the answer you wanted. - Member expressions.
<motion.div />is skipped. Some libraries forward unknown props to the DOM and some do not, and guessing wrong puts a React warning in someone else's console. - Anything outside
.jsxand.tsx, and anything innode_modules.
It will not break your build
Every failure path returns the original source: a file that will not parse, a bundler that hands the loader no options, an unexpected throw anywhere in the transform. A feedback tool has no business failing a deploy.
Implementation
A loader rule, not a Babel plugin and not an SWC plugin. Babel would take a
Next app off Turbopack and slow every build; an SWC plugin is Rust compiled to
WASM and pinned to Next's internal version. This parses with @babel/parser
and splices attributes in with magic-string — the file that comes out is the
file that went in, plus attributes.
src/core.js the transform, and the rules about what to stamp
src/env.js when to run, and what the build id is
src/loader.cjs the webpack/turbopack shim
src/next.js withSkyelight()
src/vite.js skyelight()
src/setup.js the config editing behind `init`
src/cli.js `skyelight-build init`npm test