@blueforge-studio/essentials-kit

Shared React components every app needs: sheet/dialog (consent, PWA install and legal subpaths follow). React-only peers, props-only copy, CSS-variable theming

Package Info

Latest Version0.4.1
LicenseMIT
Keywordsblueforge, react, sheet, dialog, consent, pwa
Repositorygit+https://github.com/blueforge-studio/blueforge-org.git
Author-
Homepagehttps://github.com/blueforge-studio/blueforge-org#readme
Bugs[object Object]

Versions

VersionPublished
0.4.12026-10-16
0.4.02026-10-16
0.3.02026-10-16
0.2.02026-10-16
0.1.02026-10-16

Per-version supply chain

VersionSigSBOMAtt
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0

Click a version to view its details.

Supply chain

Provenance

Published by
web:5
When
2026-10-16T08:49:13Z
sha256 (registry-computed)
3f964cb1fadc20ed975281be4d28e70b0862cf61c9eab1a57da2cb42fd93a423

Observed by the registry at publish.

Claimed by the build

The publisher claimed nothing about its build.

Attachments

Signature
none
SBOM
none

Dependencies

Loading dependency graph...

README

@blueforge-studio/essentials-kit

Shared React components every app needs, with React-only peers (react, react-dom >= 19): works in Vite and Next. Copy comes in as props, colours as CSS variables, so apps (and later the marketing CMS) can customise freely. Coming from lq-core PR #165? See MIGRATING-FROM-LQ-CORE.md. Design: docs/superpowers/specs/2026-10-16-essentials-kit-design.md.

Subpaths: /sheet, /pwa, /legal, /consent.

Install

pnpm add @blueforge-studio/essentials-kit
import '@blueforge-studio/essentials-kit/styles.css';
import { Sheet } from '@blueforge-studio/essentials-kit/sheet';

Sheet

Modal bottom sheet on narrow screens, centred dialog on wide ones. Focus trap, Escape to close, focus restored to the opener, page scroll locked (reference-counted, so stacked sheets are safe), role="dialog" + aria-modal + labelled by title. Rendered in a portal on document.body.

<Sheet open={open} onClose={() => setOpen(false)} title="Cookie preferences" closeLabel="Close" dismissOnBackdrop={false}>
  ...
</Sheet>
PropDefaultNotes
open, onClose, title, childrenrequiredtitle is the accessible name and the visible heading
placementautoauto (bottom < 640px, centred above) / bottom / center
dismissOnBackdroptrueset false for decisions that must be explicit
dismissOnEscapetrue
initialFocusReffirst focusable
closeLabel"Close"null renders no close button; translate by passing a string
classNameextra class on the panel

Theming: --ek-bg, --ek-fg, --ek-border, --ek-radius, --ek-sheet-width, --ek-z, --ek-backdrop, --ek-ring; falls back to shadcn tokens (--card, --card-foreground, --border, --ring), then to built-in colours.

Not included: animation beyond a short rise (respects prefers-reduced-motion), drag-to-dismiss, nested focus scopes.

InstallPrompt (/pwa)

import { InstallPrompt } from '@blueforge-studio/essentials-kit/pwa';

<InstallPrompt appName="LearnQuest" hold={!cookieNoticeAnswered} storageKey="lq_install" labels={{ maybeLater: 'Senere' }} />
  • Chromium/Android: a small banner that captures beforeinstallprompt and triggers the browser's own prompt.
  • iOS Safari (which never fires that event): a sheet with the Share -> Add to Home Screen steps and a pointer at the Share button (bottom on iPhone, top-right on iPad).
  • iOS in-app browsers / other browsers: a sheet telling the user to open Safari, with a copy-link button.
  • Nothing shows when the app is already installed (standalone, navigator.standalone, Capacitor), on desktop, or while hold is true.
  • Dismissals snooze 7, 30, then 90 days, then stop. "Don't ask again" and a completed install are permanent. Set storageKey per app if several share an origin.
PropDefaultNotes
appNamerequiredsubstituted for {app} in the text
labelsEnglishevery string; tokens {app}, {where} (iPhone/iPad), {browser}
iosDelayMs3000keeps the iOS sheet off the first paint
holdfalsee.g. until a cookie notice is answered
storageKey, onEvent`onEvent('captured'
env, isMobiledetectedoverrides for tests and previews

Building blocks are exported too: useInstallPrompt, IosInstallSheet, SharePointer, detectInstallPlatform, classifyIosBrowser, readDismissState, recordDismissal, isSnoozed. Extracted from LearnQuest's lq-core (PR #165). Known limitation: the Messages/Mail link preview sends a stock Safari UA and looks like Safari. The iOS sheet is modal (it uses Sheet), unlike LearnQuest's non-modal card.

LegalDocument and Markdown (/legal)

import { LegalDocument } from '@blueforge-studio/essentials-kit/legal';

<LegalDocument source={privacyMarkdown} entity={{ releaseStage: 'live', company: { name: 'Acme ApS' } }} renderLink={RouterLink} />

Renders one legal document from Markdown you supply (a file, the CMS, anywhere). {{company.name}}-style placeholders are filled from entity; unknown ones stay visible and are listed in an alert, so a missing value is never silently blank. A provisional notice shows unless entity.releaseStage === 'live' (override with provisional; reword with provisionalNotice / unresolvedLabel). No page chrome: the app owns its header, footer and routing.

Markdown is a small renderer (headings, paragraphs, lists, tables, blockquotes, rules, bold, italic, code, links). It builds React elements and never injects raw HTML; link targets are limited to http(s), mailto and in-app paths, so a javascript: URL becomes plain text. Pass renderLink to use your router's Link (internal is true for in-app paths). Style it with --ek-* variables (.ek-md). Not supported: nested lists beyond continuation lines, images, footnotes, inline HTML. What the wording must say is the app's and counsel's call.

ConsentManager and createConsentStore (/consent)

import { createConsentStore, ConsentManager } from '@blueforge-studio/essentials-kit/consent';

// Once, at module level. `version` is required: bumping it is a legal decision that forces a re-ask.
export const consent = createConsentStore({ key: 'myapp_consent', version: 1, categories: ['analytics'] });

<ConsentManager
  store={consent}
  categories={[{ id: 'analytics', title: 'Analytics', description: 'Anonymous usage statistics. Off unless you allow it.' }]}
  policyHref="/cookies"
  labels={{ bannerText: '...' }}
/>

if (consent.has('analytics')) startAnalytics();          // undecided === no
consent.subscribe(() => { /* start/stop on change; also fires for other tabs */ });
<button onClick={() => consent.requestPreferences()}>Manage cookies</button>
  • Strictly-necessary storage is shown as "always on" and is not a category. Every optional category is off until opted in.
  • The banner has "Choose", "Essential only" and "Allow all"; the last two are styled identically so declining is as easy as accepting. Closing the dialog without saving records no decision.
  • The record stores { version, decidedAt, choices, content? }. Pass content: { id, version } (e.g. the CMS item id and version of the cookie text the user saw) so a decision can be audited later.
  • A choice made under another version is ignored, so people are asked again. If storage is blocked the choice still applies for the page view.
  • All strings are labels; pass translations (or CMS text). renderPolicyLink takes your router's Link.
  • Framework-free core: createConsentStore works without React; useConsent(store) is the React hook.

The package does storage and gating. Whether a banner is required, what the wording must say and when a re-ask is legally required are the app's and counsel's call. It does not load or block any scripts itself.