diff --git a/README.md b/README.md
index d5ce55f..fab08bd 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,60 @@
[](https://github.com/shiftbloom-studio/birthday-cake-loading)
[](https://github.com/shiftbloom-studio/birthday-cake-loading/actions)
+## ⚡ 30-second setup (existing project, no Provider/Layer)
+
+**Before (`app/page.tsx`):**
+
+```tsx
+import Hero from "./_components/hero";
+import Gallery from "./_components/gallery";
+import Footer from "./_components/footer";
+
+export default function Page() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+**After (`app/page.tsx`) with data attributes + runtime init:**
+
+```tsx
+"use client";
+
+import { useEffect } from "react";
+import { initCakeRuntime } from "@shiftbloom-studio/birthday-cake-loading";
+import Hero from "./_components/hero";
+import HeroLite from "./_components/hero-lite";
+import Gallery from "./_components/gallery";
+import GalleryLite from "./_components/gallery-lite";
+import Footer from "./_components/footer";
+
+export default function Page() {
+ useEffect(() => initCakeRuntime(), []);
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+`initCakeRuntime()` automatically injects default CSS for `[data-cake-tier]` and `[caketier]` selectors, so no extra classes or CSS are required.
+
+This keeps existing component structure and removes the need for `CakeProvider` / `CakeLayer` in simple projects.
+
## 🚀 Quickstart
```bash
@@ -133,6 +187,10 @@ an **optional, coarse signal matrix** that nudges tiers without invasive fingerp
The built-in matrix uses only **non-unique** signals (reduced motion/data, coarse
memory/CPU, mobile hint, and network class) and can be overridden by ID.
+By default, BCL now enables the signal matrix with conservative defaults (offline, save-data + low bandwidth, reduced-motion + high contrast, and mobile low-memory/network protections). Most apps can ship with zero custom rules and only override for special cases.
+
+BCL also publishes runtime state to `` (tier, readiness, motion, data-saving, connectivity, etc.), which makes CSS-only layering straightforward for existing projects without reworking component trees.
+
## 🔌 Server bootstrap (Next.js)
```ts
diff --git a/package-lock.json b/package-lock.json
index 9109e18..ece13b1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3675,9 +3675,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3692,9 +3689,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3709,9 +3703,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3726,9 +3717,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3743,9 +3731,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3760,9 +3745,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3777,9 +3759,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -3794,9 +3773,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
diff --git a/src/cake-tier-visibility.ts b/src/cake-tier-visibility.ts
new file mode 100644
index 0000000..0339571
--- /dev/null
+++ b/src/cake-tier-visibility.ts
@@ -0,0 +1,50 @@
+import type { CakeTier } from "./types";
+
+export type CakeTierSelector = CakeTier | `${CakeTier}+` | `${CakeTier}-`;
+
+const STYLE_ID = "bcl-default-tier-visibility";
+
+const selectorFor = (selector: CakeTierSelector, hiddenTier: CakeTier) => {
+ if (selector.endsWith("+")) {
+ const base = selector.slice(0, -1) as CakeTier;
+ const order: CakeTier[] = ["base", "lite", "rich", "ultra"];
+ return order.indexOf(hiddenTier) < order.indexOf(base);
+ }
+ if (selector.endsWith("-")) {
+ const base = selector.slice(0, -1) as CakeTier;
+ const order: CakeTier[] = ["base", "lite", "rich", "ultra"];
+ return order.indexOf(hiddenTier) > order.indexOf(base);
+ }
+ return selector !== hiddenTier;
+};
+
+const supportedSelectors: CakeTierSelector[] = [
+ "base", "lite", "rich", "ultra",
+ "base+", "lite+", "rich+", "ultra+",
+ "base-", "lite-", "rich-", "ultra-"
+];
+
+export const ensureCakeTierVisibilityStyles = () => {
+ if (typeof document === "undefined" || document.getElementById(STYLE_ID)) {
+ return;
+ }
+
+ const tiers: CakeTier[] = ["base", "lite", "rich", "ultra"];
+ const rules: string[] = [];
+
+ for (const activeTier of tiers) {
+ for (const selector of supportedSelectors) {
+ if (!selectorFor(selector, activeTier)) {
+ continue;
+ }
+ rules.push(`html[data-bcl-tier="${activeTier}"] [data-cake-tier="${selector}"], html[data-bcl-tier="${activeTier}"] [caketier="${selector}"] { display: none !important; }`);
+ }
+ }
+
+ const style = document.createElement("style");
+ style.id = STYLE_ID;
+ style.dataset.bcl = "tier-visibility";
+ style.textContent = rules.join("\n");
+ const head = document.head ?? document.getElementsByTagName("head")[0] ?? document.documentElement;
+ head.appendChild(style);
+};
diff --git a/src/config.ts b/src/config.ts
index b782467..a2b2980 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -20,7 +20,7 @@ export const DEFAULT_CONFIG: CakeConfig = {
sensitivity: "medium"
},
advanced: {
- signalMatrix: false
+ signalMatrix: true
},
debug: false,
watchSignals: true
diff --git a/src/context.tsx b/src/context.tsx
index f389850..458994c 100644
--- a/src/context.tsx
+++ b/src/context.tsx
@@ -154,6 +154,8 @@ export const CakeProvider = ({
html.dataset.bclPrivacy = String(state.features.privacyBanner);
html.dataset.bclRichImages = String(state.features.richImages);
html.dataset.bclSaveData = String(Boolean(state.signals.saveData));
+ html.dataset.bclOnline = String(state.signals.online ?? true);
+ html.dataset.bclContrastMore = String(Boolean(state.signals.prefersContrastMore));
if (state.override) {
html.dataset.bclOverride = state.override;
diff --git a/src/index.ts b/src/index.ts
index 95bbd49..dc5c8c3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -36,3 +36,7 @@ export type { CakeUpgradeContainerTag, CakeUpgradeProps, CakeUpgradeStrategy } f
export { CakeDevTools } from "./devtools";
export type { CakeDevToolsProps } from "./devtools";
export { CakeWatch, CakeWatchtower } from "./watchtower";
+
+export { initCakeRuntime } from "./runtime";
+export { ensureCakeTierVisibilityStyles } from "./cake-tier-visibility";
+export type { CakeTierSelector } from "./cake-tier-visibility";
diff --git a/src/jsx.d.ts b/src/jsx.d.ts
new file mode 100644
index 0000000..fe08328
--- /dev/null
+++ b/src/jsx.d.ts
@@ -0,0 +1,7 @@
+import type { CakeTierSelector } from "./cake-tier-visibility";
+
+declare module "react" {
+ interface Attributes {
+ cakeTier?: CakeTierSelector;
+ }
+}
diff --git a/src/runtime.ts b/src/runtime.ts
new file mode 100644
index 0000000..07c8251
--- /dev/null
+++ b/src/runtime.ts
@@ -0,0 +1,78 @@
+import { DEFAULT_CONFIG } from "./config";
+import { detectSignals, subscribeToSignalChanges } from "./signals";
+import { resolveCakeTier } from "./tier";
+import { resolveCakeFeatures } from "./features";
+import { applySignalMatrix } from "./signal-matrix";
+import type { CakeConfig, CakeState } from "./types";
+import { ensureCakeTierVisibilityStyles } from "./cake-tier-visibility";
+
+const mergeConfig = (config?: Partial): CakeConfig => ({
+ ...DEFAULT_CONFIG,
+ ...config,
+ tiering: {
+ ...DEFAULT_CONFIG.tiering,
+ ...config?.tiering
+ },
+ features: {
+ ...DEFAULT_CONFIG.features,
+ ...config?.features
+ },
+ watchtower: {
+ ...DEFAULT_CONFIG.watchtower,
+ ...config?.watchtower
+ },
+ advanced: {
+ ...DEFAULT_CONFIG.advanced,
+ ...config?.advanced
+ }
+});
+
+const writeDataset = (state: CakeState) => {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ const html = document.documentElement;
+ html.dataset.bclTier = state.tier;
+ html.dataset.bclReady = String(state.ready);
+ html.dataset.bclMotion = String(state.features.motion);
+ html.dataset.bclSmoothScroll = String(state.features.smoothScroll);
+ html.dataset.bclAudio = String(state.features.audio);
+ html.dataset.bclPrivacy = String(state.features.privacyBanner);
+ html.dataset.bclRichImages = String(state.features.richImages);
+ html.dataset.bclSaveData = String(Boolean(state.signals.saveData));
+ html.dataset.bclOnline = String(state.signals.online ?? true);
+ html.dataset.bclContrastMore = String(Boolean(state.signals.prefersContrastMore));
+
+ if (state.signals.effectiveType) {
+ html.dataset.bclEct = state.signals.effectiveType;
+ } else {
+ delete html.dataset.bclEct;
+ }
+};
+
+export const initCakeRuntime = (config?: Partial) => {
+ const mergedConfig = mergeConfig(config);
+ ensureCakeTierVisibilityStyles();
+
+ const refresh = () => {
+ const signals = detectSignals();
+ const initialTier = resolveCakeTier(signals, mergedConfig);
+ const tier = applySignalMatrix(initialTier, signals, mergedConfig);
+ const state: CakeState = {
+ signals,
+ tier,
+ features: resolveCakeFeatures(tier, signals, mergedConfig),
+ ready: true
+ };
+ writeDataset(state);
+ };
+
+ refresh();
+
+ if (!mergedConfig.watchSignals) {
+ return () => undefined;
+ }
+
+ return subscribeToSignalChanges(refresh);
+};
diff --git a/src/signal-matrix.ts b/src/signal-matrix.ts
index cca256e..b2dadca 100644
--- a/src/signal-matrix.ts
+++ b/src/signal-matrix.ts
@@ -15,6 +15,32 @@ const TIER_RANK: Record = {
};
const DEFAULT_SIGNAL_MATRIX_RULES: CakeSignalMatrixRule[] = [
+ {
+ id: "offline-always-base",
+ description: "Use baseline when browser reports no network connectivity.",
+ when: {
+ online: false
+ },
+ adjust: { setTier: "base" }
+ },
+ {
+ id: "save-data-low-downlink",
+ description: "Cap tier on explicit data saving with low downlink.",
+ when: {
+ saveData: true,
+ maxDownlinkMbps: 1.2
+ },
+ adjust: { maxTier: "lite" }
+ },
+ {
+ id: "reduced-motion-high-contrast",
+ description: "Treat reduced motion + high contrast as strong low-motion preference.",
+ when: {
+ prefersReducedMotion: true,
+ prefersContrastMore: true
+ },
+ adjust: { maxTier: "lite" }
+ },
{
id: "reduced-motion-mobile-low-memory",
description: "Conservative downgrade for reduced motion + low-memory mobile devices.",
@@ -111,6 +137,16 @@ const matchesCommonConditions = (signals: CakeSignals, when: CakeSignalMatrixCon
!matchesBoolean(signals.userAgentMobile, when.userAgentMobile)
) return false;
+ if (
+ typeof when.online === "boolean" &&
+ !matchesBoolean(signals.online, when.online)
+ ) return false;
+
+ if (
+ typeof when.prefersContrastMore === "boolean" &&
+ !matchesBoolean(signals.prefersContrastMore, when.prefersContrastMore)
+ ) return false;
+
return true;
};
diff --git a/src/signals.ts b/src/signals.ts
index fbbab4b..02734be 100644
--- a/src/signals.ts
+++ b/src/signals.ts
@@ -36,10 +36,16 @@ export const detectSignals = (): CakeSignals => {
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-data: reduce)").matches;
- const uaData = (navigator as Navigator & { userAgentData?: { mobile?: boolean } })
- .userAgentData;
+ const uaData = (navigator as Navigator & { userAgentData?: { mobile?: boolean } }).userAgentData;
+ const userAgent = (navigator as Navigator & { userAgent?: string }).userAgent;
+ const userAgentMobileFallback = typeof userAgent === "string"
+ ? /Android|iPhone|iPad|iPod|IEMobile|Opera Mini|Mobile/i.test(userAgent)
+ : undefined;
const effectiveType = connection?.effectiveType;
+ const prefersContrastMore =
+ typeof window.matchMedia === "function" &&
+ window.matchMedia("(prefers-contrast: more)").matches;
return {
saveData: connection?.saveData,
@@ -53,7 +59,9 @@ export const detectSignals = (): CakeSignals => {
screenHeight: window.screen?.height,
prefersReducedMotion,
prefersReducedData,
- userAgentMobile: uaData?.mobile
+ userAgentMobile: uaData?.mobile ?? userAgentMobileFallback,
+ online: navigator.onLine,
+ prefersContrastMore
};
};
@@ -69,6 +77,9 @@ export const subscribeToSignalChanges = (callback: () => void) => {
const reducedDataQuery = window.matchMedia
? window.matchMedia("(prefers-reduced-data: reduce)")
: null;
+ const contrastQuery = window.matchMedia
+ ? window.matchMedia("(prefers-contrast: more)")
+ : null;
const addMediaListener = (mql: MediaQueryList | null, cb: () => void) => {
if (!mql) {
@@ -108,6 +119,7 @@ export const subscribeToSignalChanges = (callback: () => void) => {
addMediaListener(reducedMotionQuery, callback);
addMediaListener(reducedDataQuery, callback);
+ addMediaListener(contrastQuery, callback);
let resizeRaf: number | null = null;
const onResize = () => {
@@ -122,6 +134,10 @@ export const subscribeToSignalChanges = (callback: () => void) => {
window.addEventListener("resize", onResize);
window.addEventListener("orientationchange", onResize);
+ window.addEventListener("online", callback);
+ window.addEventListener("offline", callback);
+ document.addEventListener("visibilitychange", callback);
+ window.addEventListener("pageshow", callback);
return () => {
if (typeof connection?.removeEventListener === "function") {
@@ -132,9 +148,14 @@ export const subscribeToSignalChanges = (callback: () => void) => {
removeMediaListener(reducedMotionQuery, callback);
removeMediaListener(reducedDataQuery, callback);
+ removeMediaListener(contrastQuery, callback);
window.removeEventListener("resize", onResize);
window.removeEventListener("orientationchange", onResize);
+ window.removeEventListener("online", callback);
+ window.removeEventListener("offline", callback);
+ document.removeEventListener("visibilitychange", callback);
+ window.removeEventListener("pageshow", callback);
if (resizeRaf !== null) {
window.cancelAnimationFrame(resizeRaf);
diff --git a/src/types.ts b/src/types.ts
index 4c44a7a..b85220a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -30,6 +30,8 @@ export interface CakeSignals {
prefersReducedMotion?: boolean;
prefersReducedData?: boolean;
userAgentMobile?: boolean;
+ online?: boolean;
+ prefersContrastMore?: boolean;
}
export interface CakeBootstrap {
@@ -85,6 +87,8 @@ export interface CakeSignalMatrixCondition {
prefersReducedData?: boolean;
saveData?: boolean;
userAgentMobile?: boolean;
+ online?: boolean;
+ prefersContrastMore?: boolean;
effectiveType?: ConnectionType | ConnectionType[];
maxDeviceMemoryGB?: number;
minDeviceMemoryGB?: number;
diff --git a/tests/runtime.test.ts b/tests/runtime.test.ts
new file mode 100644
index 0000000..19d0256
--- /dev/null
+++ b/tests/runtime.test.ts
@@ -0,0 +1,17 @@
+import { initCakeRuntime } from "../src/runtime";
+
+describe("initCakeRuntime", () => {
+ it("writes tier state to html dataset", () => {
+ const stop = initCakeRuntime({ watchSignals: false });
+ expect(document.documentElement.dataset.bclReady).toBe("true");
+ expect(document.documentElement.dataset.bclTier).toBeDefined();
+ stop();
+ });
+
+ it("injects default visibility css for [data-cake-tier]", () => {
+ initCakeRuntime({ watchSignals: false });
+ const style = document.getElementById("bcl-default-tier-visibility");
+ expect(style?.textContent).toContain('[data-cake-tier="rich"]');
+ expect(style?.textContent).toContain('[caketier="rich"]');
+ });
+});