Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,60 @@
[![GitHub stars](https://img.shields.io/github/stars/shiftbloom-studio/birthday-cake-loading?style=social)](https://github.com/shiftbloom-studio/birthday-cake-loading)
[![CI](https://img.shields.io/github/actions/workflow/status/shiftbloom-studio/birthday-cake-loading/ci.yml)](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 (
<main>
<Hero />
<Gallery />
<Footer />
</main>
);
}
```

**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 (
<main>
<section data-cake-tier="rich+"><Hero /></section>
<section data-cake-tier="lite-"><HeroLite /></section>

<Gallery cakeTier="rich+" />
<GalleryLite cakeTier="lite-" />

<Footer />
</main>
);
}
```

`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
Expand Down Expand Up @@ -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 `<html data-bcl-*>` (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
Expand Down
24 changes: 0 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions src/cake-tier-visibility.ts
Original file line number Diff line number Diff line change
@@ -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);
};
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const DEFAULT_CONFIG: CakeConfig = {
sensitivity: "medium"
},
advanced: {
signalMatrix: false
signalMatrix: true
},
debug: false,
watchSignals: true
Expand Down
2 changes: 2 additions & 0 deletions src/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 7 additions & 0 deletions src/jsx.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { CakeTierSelector } from "./cake-tier-visibility";

declare module "react" {
interface Attributes {
cakeTier?: CakeTierSelector;
}
}
78 changes: 78 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
@@ -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>): 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<CakeConfig>) => {
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);
};
36 changes: 36 additions & 0 deletions src/signal-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,32 @@ const TIER_RANK: Record<CakeTier, number> = {
};

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.",
Expand Down Expand Up @@ -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;
};

Expand Down
Loading