From 6e116dfde128e795d22c0bad281eb6a897c0cb52 Mon Sep 17 00:00:00 2001 From: Mekphen Date: Sat, 2 May 2026 03:17:52 +0200 Subject: [PATCH 1/2] feat: add automatic cakeTier attribute visibility controls --- README.md | 58 +++++++++++++++++++++++++++ src/cake-tier-visibility.ts | 49 +++++++++++++++++++++++ src/config.ts | 2 +- src/context.tsx | 2 + src/index.ts | 4 ++ src/jsx.d.ts | 7 ++++ src/runtime.ts | 78 +++++++++++++++++++++++++++++++++++++ src/signal-matrix.ts | 36 +++++++++++++++++ src/signals.ts | 27 +++++++++++-- src/types.ts | 5 +++ tests/runtime.test.ts | 17 ++++++++ 11 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 src/cake-tier-visibility.ts create mode 100644 src/jsx.d.ts create mode 100644 src/runtime.ts create mode 100644 tests/runtime.test.ts diff --git a/README.md b/README.md index d5ce55f..fab08bd 100644 --- a/README.md +++ b/README.md @@ -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 ( +
+ + +
+
+ ); +} +``` + +**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/src/cake-tier-visibility.ts b/src/cake-tier-visibility.ts new file mode 100644 index 0000000..ab15dc1 --- /dev/null +++ b/src/cake-tier-visibility.ts @@ -0,0 +1,49 @@ +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"); + document.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..d578e99 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 as CakeSignalMatrixCondition & { online?: boolean }).online === "boolean" && + !matchesBoolean((signals as CakeSignals & { online?: boolean }).online, (when as CakeSignalMatrixCondition & { online?: boolean }).online as boolean) + ) return false; + + if ( + typeof (when as CakeSignalMatrixCondition & { prefersContrastMore?: boolean }).prefersContrastMore === "boolean" && + !matchesBoolean((signals as CakeSignals & { prefersContrastMore?: boolean }).prefersContrastMore, (when as CakeSignalMatrixCondition & { prefersContrastMore?: boolean }).prefersContrastMore as boolean) + ) 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..9bebc7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,8 +30,11 @@ export interface CakeSignals { prefersReducedMotion?: boolean; prefersReducedData?: boolean; userAgentMobile?: boolean; + online?: boolean; + prefersContrastMore?: boolean; } + export interface CakeBootstrap { /** * Optional precomputed signals (e.g. from Client Hints headers on the server). @@ -85,6 +88,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"]'); + }); +}); From dea7f7c17497953082b19a481b49d33b0f784c64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 02:06:03 +0000 Subject: [PATCH 2/2] fix: remove redundant type casts, extra blank line, guard document.head Agent-Logs-Url: https://github.com/shiftbloom-studio/birthday-cake-loading/sessions/a5a2acf8-6d05-40a2-93a0-9385eb22a5e7 Co-authored-by: fabianzimber <31100894+fabianzimber@users.noreply.github.com> --- package-lock.json | 24 ------------------------ src/cake-tier-visibility.ts | 3 ++- src/signal-matrix.ts | 8 ++++---- src/types.ts | 1 - 4 files changed, 6 insertions(+), 30 deletions(-) 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 index ab15dc1..0339571 100644 --- a/src/cake-tier-visibility.ts +++ b/src/cake-tier-visibility.ts @@ -45,5 +45,6 @@ export const ensureCakeTierVisibilityStyles = () => { style.id = STYLE_ID; style.dataset.bcl = "tier-visibility"; style.textContent = rules.join("\n"); - document.head.appendChild(style); + const head = document.head ?? document.getElementsByTagName("head")[0] ?? document.documentElement; + head.appendChild(style); }; diff --git a/src/signal-matrix.ts b/src/signal-matrix.ts index d578e99..b2dadca 100644 --- a/src/signal-matrix.ts +++ b/src/signal-matrix.ts @@ -138,13 +138,13 @@ const matchesCommonConditions = (signals: CakeSignals, when: CakeSignalMatrixCon ) return false; if ( - typeof (when as CakeSignalMatrixCondition & { online?: boolean }).online === "boolean" && - !matchesBoolean((signals as CakeSignals & { online?: boolean }).online, (when as CakeSignalMatrixCondition & { online?: boolean }).online as boolean) + typeof when.online === "boolean" && + !matchesBoolean(signals.online, when.online) ) return false; if ( - typeof (when as CakeSignalMatrixCondition & { prefersContrastMore?: boolean }).prefersContrastMore === "boolean" && - !matchesBoolean((signals as CakeSignals & { prefersContrastMore?: boolean }).prefersContrastMore, (when as CakeSignalMatrixCondition & { prefersContrastMore?: boolean }).prefersContrastMore as boolean) + typeof when.prefersContrastMore === "boolean" && + !matchesBoolean(signals.prefersContrastMore, when.prefersContrastMore) ) return false; return true; diff --git a/src/types.ts b/src/types.ts index 9bebc7a..b85220a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,7 +34,6 @@ export interface CakeSignals { prefersContrastMore?: boolean; } - export interface CakeBootstrap { /** * Optional precomputed signals (e.g. from Client Hints headers on the server).