diff --git a/src/components/Cards/CardRenderer.jsx b/src/components/Cards/CardRenderer.jsx
new file mode 100644
index 0000000..d1652a6
--- /dev/null
+++ b/src/components/Cards/CardRenderer.jsx
@@ -0,0 +1,195 @@
+import React, { useMemo } from "react";
+
+function getByPath(obj, path) {
+ if (!path) return undefined;
+ const parts = String(path).split(".");
+ let cur = obj;
+ for (const p of parts) {
+ if (cur == null) return undefined;
+ cur = cur[p];
+ }
+ return cur;
+}
+
+function resolveStyleToken(styleKey, tokens) {
+ const key = styleKey || "";
+ const [group, name] = key.split(".");
+ if (!group || !name) return {};
+ const groupObj = tokens?.[group];
+ return groupObj?.[name] ?? {};
+}
+
+function ClampText({ text, maxLines }) {
+ if (!maxLines || maxLines <= 0) return {text};
+ return (
+
+ {text}
+
+ );
+}
+
+function LayerView({ layer, ctx, themeTokens, onAction }) {
+ const common = {
+ position: "absolute",
+ left: layer.x,
+ top: layer.y,
+ width: layer.w || "auto",
+ height: layer.h || "auto",
+ borderRadius: layer.radius ?? undefined,
+ };
+
+ const styleToken = resolveStyleToken(layer.style, themeTokens);
+ const clickable = !!layer.action;
+
+ const handleClick = () => {
+ if (!layer.action || !onAction) return;
+ const resolved = layer.action.bind
+ ? getByPath(ctx, layer.action.bind)
+ : layer.action.value;
+ onAction(layer.action, resolved);
+ };
+
+ if (layer.type === "text") {
+ const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
+ return (
+
+
+
+ );
+ }
+
+ if (layer.type === "chip") {
+ const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
+ return (
+
+ {String(value ?? "")}
+
+ );
+ }
+
+ if (layer.type === "image") {
+ const src = layer.bind ? getByPath(ctx, layer.bind) : undefined;
+ return (
+
+ );
+ }
+
+ if (layer.type === "button") {
+ const label = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
+ return (
+
+ );
+ }
+
+ if (layer.type === "icon") {
+ const value = layer.bind ? getByPath(ctx, layer.bind) : layer.text;
+ return {String(value ?? "•")}
;
+ }
+
+ return null;
+}
+
+export default function CardRenderer({
+ spec,
+ data,
+ themeTokens,
+ onAction,
+ className = "",
+}) {
+ const ctx = useMemo(() => ({ data }), [data]);
+
+ const bgStyle = useMemo(() => {
+ const bg = spec?.background || {};
+ if (bg.type === "solid") {
+ return { background: themeTokens.colors?.[bg.value] ?? bg.value };
+ }
+ if (bg.type === "gradient") {
+ return { background: themeTokens.gradients?.[bg.value] ?? bg.value };
+ }
+ if (bg.type === "image") {
+ const url = bg.bind ? getByPath(ctx, bg.bind) : undefined;
+ return url
+ ? {
+ backgroundImage: `url(${url})`,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ }
+ : { background: themeTokens.colors?.surface ?? "#111" };
+ }
+ return { background: themeTokens.colors?.surface ?? "#111" };
+ }, [spec, themeTokens, ctx]);
+
+ if (!spec) return null;
+
+ return (
+
+ {spec.layers.map((layer) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/Posts/PostCardTemplate.jsx b/src/components/Posts/PostCardTemplate.jsx
new file mode 100644
index 0000000..2d5298c
--- /dev/null
+++ b/src/components/Posts/PostCardTemplate.jsx
@@ -0,0 +1,60 @@
+import React, { useMemo } from "react";
+import CardRenderer from "../Cards/CardRenderer";
+import { cardTokens } from "../../theme/cardTokens";
+
+// Spec NEWS (mini) identique à celui en DB (pour demo local)
+const NEWS_MINI_SPEC = {
+ size: { w: 280, h: 110 },
+ background: { type: "gradient", value: "newsBlue" },
+ radius: 18,
+ layers: [
+ { id: "badge", type: "chip", x: 14, y: 12, text: "NEWS", style: "chip.news" },
+ { id: "title", type: "text", x: 14, y: 40, w: 252, h: 44, bind: "data.headline", style: "text.title", maxLines: 2 },
+ { id: "meta", type: "text", x: 14, y: 88, w: 252, h: 16, bind: "data.source", style: "text.meta", maxLines: 1 }
+ ],
+};
+
+export default function PostCardTemplate({ post, theme = "blue", onOpen }) {
+ // Adaptation depuis ton objet post actuel vers data_json style template
+ const data = useMemo(() => {
+ return {
+ headline: post.title || "Untitled",
+ source: post.author ? `by ${post.author}` : (post.sub_category || ""),
+ summary: post.snippet || post.body || "",
+ url: post.url || "",
+ image: post.image || post.media_url || "",
+ };
+ }, [post]);
+
+ return (
+
+ {
+ if (action.type === "open_url" && value) window.open(value, "_blank");
+ }}
+ className=""
+ />
+ {onOpen && (
+
+ )}
+
+ );
+}
diff --git a/src/theme/cardTokens.js b/src/theme/cardTokens.js
new file mode 100644
index 0000000..043149c
--- /dev/null
+++ b/src/theme/cardTokens.js
@@ -0,0 +1,85 @@
+export const cardTokens = {
+ dark: {
+ colors: {
+ surface: "#101218",
+ text: "#E9EEF8",
+ muted: "#A9B3C7",
+ accent: "#4EA1FF",
+ danger: "#FF4D4D",
+ },
+ gradients: {
+ newsBlue: "linear-gradient(135deg, rgba(78,161,255,0.35), rgba(16,18,24,1))",
+ },
+ radius: { card: 18 },
+ shadow: { card: "0 10px 30px rgba(0,0,0,0.35)" },
+ text: {
+ title: { color: "#E9EEF8", fontSize: 16, fontWeight: 800, lineHeight: "20px" },
+ meta: { color: "#A9B3C7", fontSize: 12, fontWeight: 600 },
+ h1: { color: "#E9EEF8", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
+ body: { color: "#D7DEED", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
+ },
+ chip: {
+ news: { background: "rgba(78,161,255,0.18)", color: "#CFE6FF", fontWeight: 800, fontSize: 12, borderRadius: 999 },
+ danger: { background: "rgba(255,77,77,0.18)", color: "#FFD1D1", fontWeight: 900, fontSize: 12, borderRadius: 999 },
+ },
+ button: {
+ primary: { background: "rgba(78,161,255,0.22)", color: "#E9EEF8", fontWeight: 900, borderRadius: 14 },
+ },
+ },
+
+ blue: {
+ colors: {
+ surface: "#071225",
+ text: "#E9EEF8",
+ muted: "#B5C7E6",
+ accent: "#38bdf8",
+ danger: "#FF4D4D",
+ },
+ gradients: {
+ newsBlue: "linear-gradient(135deg, rgba(56,189,248,0.35), rgba(7,18,37,1))",
+ },
+ radius: { card: 18 },
+ shadow: { card: "0 10px 30px rgba(0,0,0,0.40)" },
+ text: {
+ title: { color: "#F0F7FF", fontSize: 16, fontWeight: 900, lineHeight: "20px" },
+ meta: { color: "#B5C7E6", fontSize: 12, fontWeight: 700 },
+ h1: { color: "#F0F7FF", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
+ body: { color: "#D7E9FF", fontSize: 14, fontWeight: 700, lineHeight: "18px" },
+ },
+ chip: {
+ news: { background: "rgba(56,189,248,0.18)", color: "#D7F3FF", fontWeight: 900, fontSize: 12, borderRadius: 999 },
+ danger: { background: "rgba(255,77,77,0.18)", color: "#FFD1D1", fontWeight: 900, fontSize: 12, borderRadius: 999 },
+ },
+ button: {
+ primary: { background: "rgba(56,189,248,0.22)", color: "#F0F7FF", fontWeight: 900, borderRadius: 14 },
+ },
+ },
+
+ light: {
+ colors: {
+ surface: "#FFFFFF",
+ text: "#0B1220",
+ muted: "#5B677A",
+ accent: "#1E6BFF",
+ danger: "#D1001F",
+ },
+ gradients: {
+ newsBlue: "linear-gradient(135deg, rgba(30,107,255,0.20), rgba(255,255,255,1))",
+ },
+ radius: { card: 18 },
+ shadow: { card: "0 10px 24px rgba(0,0,0,0.12)" },
+ text: {
+ title: { color: "#0B1220", fontSize: 16, fontWeight: 900, lineHeight: "20px" },
+ meta: { color: "#5B677A", fontSize: 12, fontWeight: 700 },
+ h1: { color: "#0B1220", fontSize: 22, fontWeight: 900, lineHeight: "26px" },
+ body: { color: "#1A2740", fontSize: 14, fontWeight: 600, lineHeight: "18px" },
+ },
+ chip: {
+ news: { background: "rgba(30,107,255,0.12)", color: "#0B1220", fontWeight: 900, fontSize: 12, borderRadius: 999 },
+ danger: { background: "rgba(209,0,31,0.10)", color: "#0B1220", fontWeight: 900, fontSize: 12, borderRadius: 999 },
+ },
+ button: {
+ primary: { background: "rgba(30,107,255,0.14)", color: "#0B1220", fontWeight: 900, borderRadius: 14 },
+ },
+ },
+};