Framework-agnostic feature suggestion widget — distributed as a Web Component (<feature-suggestions>) on GitHub Packages.
- Submit suggestions (title, details, type)
- Browse the feed with text search and sort (Trending / Most Voted / Newest)
- Detail dialog with one-vote-per-user upvotes and a comment thread
- Admin-only status setter (Under Review / Planned / In Progress / Completed / Declined); status badge visible to all users
- Storage adapters: Firebase included (Supabase planned for v1.1)
- Themable via CSS custom properties; logo + intro tagline configurable
This package is private and hosted on GitHub Packages, not the public npm registry. You need a GitHub personal access token with the read:packages scope (and access to this repository) configured in your .npmrc.
# .npmrc
@nirioppai:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}export NODE_AUTH_TOKEN=ghp_your_personal_access_tokenThe token must have
read:packagesand access to theNirioppai/nirio_featuresrepository.
npm install @nirioppai/feature-suggestionsThe widget is a custom element. Register it once, then mount and pass the host-app user and adapter as JS properties.
<script type="module">
import {
defineWidget,
createFirebaseAdapter,
} from '@nirioppai/feature-suggestions';
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';
// 1. Register the custom element (idempotent)
defineWidget();
// 2. Initialize your Firebase app and create the adapter
const app = initializeApp({
/* your firebase config */
});
const adapter = createFirebaseAdapter(getFirestore(app));
// 3. Mount the widget and pass props
const el = document.createElement('feature-suggestions');
el.user = {
id: 'u1',
name: 'Alice',
email: 'alice@example.com',
role: 'user', // 'admin' to expose the status selector
};
el.adapter = adapter;
el.theme = { primaryColor: '#5b6cff', background: '#fff', font: 'Inter' };
el.logo = 'https://your.cdn/logo.svg';
document.body.appendChild(el);
</script>defineWidget()— registers the<feature-suggestions>element. Safe to call multiple times.createFirebaseAdapter(firestore)— factory returning aStorageAdapterbacked by Firestore.firebaseis an optional peer dependency — only install it if you use this adapter.createHttpAdapter(config)— factory returning aStorageAdapterbacked by a host REST API (cookie-auth friendly, no extra deps). See Integrating with External Projects.- Exported types:
WidgetUser,WidgetTheme,SortOption,HttpAdapterConfig,FeatureSuggestionsElement(and the underlyingSuggestion,Comment,Vote,StorageAdaptertypes fromsrc/).
The widget is a typed custom element. After import the package augments
HTMLElementTagNameMap, so document.createElement('feature-suggestions')
returns a FeatureSuggestionsElement with typed properties — no as unknown as { ... } casts required.
| Property | Type | Required | Notes |
|---|---|---|---|
user |
WidgetUser | null |
yes | { id, name, email, role }. Host app owns auth; widget trusts it. |
adapter |
StorageAdapter |
yes | One of the bundled adapters or your own StorageAdapter impl. |
theme |
WidgetTheme |
no | { primaryColor?, background?, font? } → CSS custom properties. |
logo |
string | null |
no | URL rendered above the intro tagline. |
import {
defineWidget,
type FeatureSuggestionsElement,
type WidgetUser,
type StorageAdapter,
} from '@nirioppai/feature-suggestions';
defineWidget();
const el = document.createElement('feature-suggestions'); // FeatureSuggestionsElement
el.user = {
id: '1',
name: 'A',
email: 'a@x',
role: 'user',
} satisfies WidgetUser;
el.adapter = adapter; // StorageAdapterThe admin status selector is gated on exact string equality:
user.role === 'admin'. Anything else — 'admin_user', 'administrator',
'ADMIN', null, undefined — hides the selector. Map your host app's role
taxonomy accordingly when constructing WidgetUser. All users see the
resulting status badge regardless of role.
The widget is intentionally decoupled from any specific backend through the StorageAdapter interface (src/adapter.ts). Two adapters ship with the package:
createFirebaseAdapter(firestore)— for apps that already use Firestore.createHttpAdapter(config)— for apps that own their own REST API (e.g. Laravel + Sanctum, Rails, Django, Express).
Hosts can also implement the StorageAdapter interface themselves to point the widget at any other backend.
To embed the widget the host project owns four responsibilities:
- Authentication — the widget never logs anyone in. The host authenticates the user (cookie session, JWT, OAuth, whatever) and passes the resulting identity in as the
userprop. - A
WidgetUser—{ id, name, email, role }. Map your internal roles to either'user'or'admin'. The exact stringuser.role === 'admin'is the only thing that exposes the status selector. - A storage adapter — either one of the bundled adapters (configured for your backend) or a custom implementation of
StorageAdapter. - CSRF / session bootstrap (if applicable) — for cookie sessions, run any required pre-flight (e.g. Laravel Sanctum's
GET /sanctum/csrf-cookie) before mounting the widget. The HTTP adapter does not initialize CSRF; it only reads the cookie and forwards it as a header.
- The
StorageAdapterinterface insrc/adapter.tsis the integration boundary. Anything that implements those eight methods is a valid backend. - The widget owns all rendering, event handling, optimistic updates, and shadow-DOM styling. It never calls
fetchdirectly. - The host owns auth, CSRF, identity mapping, and adapter construction.
- Types in
src/types.ts(Suggestion,Comment,Vote, etc.) are the shared vocabulary between the widget and any adapter. - Admin gating is by exact string equality on
user.role === 'admin'. Map your role taxonomy accordingly.
createHttpAdapter translates the eight StorageAdapter methods into HTTP calls against a REST resource. It uses only globalThis.fetch (no extra dependencies) and is friendly to cookie-based session auth (Laravel Sanctum, Rails session, etc.).
Endpoint contract the host must serve under {baseUrl}{resourcePath} (default resourcePath is /api/feature-suggestions):
| Method | Path | Request body | Response |
|---|---|---|---|
GET |
/ |
— | Suggestion[] |
POST |
/ |
{ title, details, type } |
Suggestion |
PATCH |
/{id}/status |
{ status } |
(any 2xx) |
GET |
/{id}/vote |
— | { suggestion_id, user_id } or 404 |
POST |
/{id}/vote |
— | (any 2xx) |
DELETE |
/{id}/vote |
— | (any 2xx) |
GET |
/{id}/comments |
— | Comment[] |
POST |
/{id}/comments |
{ body } |
Comment |
JSON shapes (server returns snake_case; the adapter maps to the camelCase types in src/types.ts):
Behavioral guarantees of the HTTP adapter:
- Every request sets
credentials: 'include'(configurable via thecredentialsoption) andAccept: 'application/json'. - Mutating requests (
POST/PATCH/PUT/DELETE) URL-decode theXSRF-TOKENcookie and send it asX-XSRF-TOKEN. Cookie + header names are configurable (csrfCookieName,csrfHeaderName); passcsrfCookieName: nullto disable. The adapter does not call/sanctum/csrf-cookie— the host primes the cookie before mounting. getVotereturnsnullon 404; all other non-2xx responses throw anErrorwhose.messageis the server'smessagefield (orHTTP {status}),.statusis the HTTP status, and.bodyis the parsed envelope.created_atISO strings are parsed toDate. Field names are mappedsnake_case→camelCase. Server IDs may be string or number; both are coerced to string in the typed result.- The
userIdargument on the vote methods is ignored on the wire — the server is expected to derive identity from the session.
| Option | Type | Default | Notes |
|---|---|---|---|
baseUrl |
string |
— (required) | API host, no trailing slash. |
resourcePath |
string |
/api/feature-suggestions |
Path mounted by the host server. |
fetch |
typeof fetch |
globalThis.fetch |
Override for tests, SSR, or instrumented transports. |
fetchImpl |
typeof fetch |
— | Deprecated alias for fetch. Removed in a future major. |
credentials |
RequestCredentials |
'include' |
Cookie session needs 'include'; token auth can use 'omit'. |
csrfCookieName |
string | null |
'XSRF-TOKEN' |
null disables the CSRF header entirely. |
csrfHeaderName |
string |
'X-XSRF-TOKEN' |
Whatever your server expects. |
End-to-end example (host owns auth + CSRF, widget renders + persists):
import {
defineWidget,
createHttpAdapter,
} from '@nirioppai/feature-suggestions';
// 1. Host bootstraps its own session + CSRF (e.g. Laravel Sanctum).
// `credentials: 'include'` is REQUIRED on this call — without it the
// XSRF-TOKEN cookie will not be set and every later mutating request
// from the widget will fail CSRF verification.
await fetch('https://api.example.com/sanctum/csrf-cookie', {
credentials: 'include',
});
const me = await fetch('https://api.example.com/api/auth/me', {
credentials: 'include',
}).then(r => r.json());
// 2. Build the adapter against the host's REST API
const adapter = createHttpAdapter({
baseUrl: 'https://api.example.com',
// resourcePath: '/api/feature-suggestions', // default
// csrfCookieName: 'XSRF-TOKEN', // default
// csrfHeaderName: 'X-XSRF-TOKEN', // default
});
// 3. Mount the widget with the host's identity
defineWidget();
const el = document.createElement('feature-suggestions');
el.user = {
id: String(me.id),
name: me.name,
email: me.email,
role: me.role === 'admin_user' ? 'admin' : 'user',
};
el.adapter = adapter;
el.theme = { primaryColor: '#5b6cff' };
el.logo = '/logo.svg';
document.body.appendChild(el);If neither bundled adapter fits, implement StorageAdapter yourself:
import type { StorageAdapter } from '@nirioppai/feature-suggestions';
const adapter: StorageAdapter = {
async getSuggestions() {
/* … */
},
async createSuggestion(input) {
/* … */
},
async setStatus(id, status) {
/* … */
},
async getVote(id, userId) {
/* … */
},
async addVote(id, userId) {
/* … */
},
async removeVote(id, userId) {
/* … */
},
async getComments(id) {
/* … */
},
async addComment(id, input) {
/* … */
},
};Pass it as el.adapter exactly the same way. The widget cannot tell the difference.
npm install
npm run typecheck
npm run build
npm testPer-phase verification commands:
npm run test:unit— fast unit suitenpm run test:consumer— built-bundle consumer integration testnpm run test:phase6,test:phase7,test:phase8— phase-scoped checksnpm run test:phase8— release gate: builds, runs the dist-bundle integration suite, and runsnpm pack --dry-run
Releases are published automatically by .github/workflows/publish.yml on version tag pushes. The workflow runs npm run test:phase8 before npm publish, so a tag cannot ship a package that fails the release verification slice.
To cut a release:
# 1. Bump the version (commits + creates a vX.Y.Z tag)
npm version patch # or minor / major
# 2. Push the commit and the tag
git push origin master --follow-tagsGitHub Actions then:
- Checks out the tagged commit
- Installs dependencies with
npm ci - Runs
npm run test:phase8(build → consumer integration →npm pack --dry-run) - Runs
npm publishagainsthttps://npm.pkg.github.comusing the workflow'sGITHUB_TOKEN
If Phase 8 verification fails, the publish step is skipped and the tag does not produce a release.
Local
npm publishis a fallback only. The tag-driven workflow is the supported release path so the verification gate cannot be bypassed.
- See
.planning/UPDATES.mdfor roadmap and changelog details. - Source of truth for behavior is the dist-bundle consumer test in
test/e2e.consumer.test.ts.