diff --git a/.dockerignore b/.dockerignore index c269af9..5e7f00f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -24,5 +24,3 @@ coverage **/.pnpm-store .gitea **/.gitea -models -**/models diff --git a/apps/image-service/Dockerfile b/apps/image-service/Dockerfile index fd77686..5b33276 100644 --- a/apps/image-service/Dockerfile +++ b/apps/image-service/Dockerfile @@ -1,6 +1,16 @@ FROM node:20.18-bookworm-slim AS base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +RUN apt-get update && apt-get install -y \ + build-essential \ + python3 \ + libcairo2-dev \ + libpango1.0-dev \ + libjpeg-dev \ + libgif-dev \ + librsvg2-dev \ + libexpat1 \ + && rm -rf /var/lib/apt/lists/* RUN npm install -g pnpm@10.30.1 FROM base AS build @@ -13,14 +23,17 @@ RUN pnpm --filter image-service build FROM base WORKDIR /app -COPY --from=build /app/node_modules ./node_modules -COPY --from=build /app/apps/image-service/node_modules ./apps/image-service/node_modules -COPY --from=build /app/packages/image-processor/node_modules ./packages/image-processor/node_modules -RUN mkdir -p /app/apps/image-service/dist +# Instead of copying node_modules which contains native C++ bindings for canvas and tfjs-node, +# we copy the package.json files and install natively in the final stage so the bindings are correct. +COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +COPY apps/image-service/package.json ./apps/image-service/package.json +COPY packages/image-processor/package.json ./packages/image-processor/package.json + +RUN pnpm install --frozen-lockfile --filter image-service... + COPY --from=build /app/apps/image-service/dist ./apps/image-service/dist -COPY --from=build /app/apps/image-service/package.json ./apps/image-service/package.json COPY --from=build /app/packages/image-processor/dist ./packages/image-processor/dist -COPY --from=build /app/packages/image-processor/package.json ./packages/image-processor/package.json +COPY --from=build /app/packages/image-processor/models ./packages/image-processor/models EXPOSE 8080 WORKDIR /app/apps/image-service diff --git a/apps/image-service/src/index.ts b/apps/image-service/src/index.ts index 0ed25ac..921ed2a 100644 --- a/apps/image-service/src/index.ts +++ b/apps/image-service/src/index.ts @@ -56,7 +56,6 @@ async function handleProcessing(url: string, options: any, reply: any) { height, format, quality, - openRouterApiKey: process.env.OPENROUTER_API_KEY, }); reply.header("Content-Type", `image/${format}`); diff --git a/optimize-images.sh b/optimize-images.sh new file mode 100644 index 0000000..1ee44f4 --- /dev/null +++ b/optimize-images.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Ghost Image Optimizer +# Target directory for Ghost content +TARGET_DIR="/home/deploy/sites/marisas.world/content/images" + +echo "Starting image optimization for $TARGET_DIR..." + +# Find all original images, excluding the 'size/' directory where Ghost stores thumbnails +# Resize images larger than 2500px down to 2500px width +# Compress JPEG/PNG to 80% quality +find "$TARGET_DIR" -type d -name "size" -prune -o \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) -type f -exec mogrify -resize '2500x>' -quality 80 {} + + +echo "Optimization complete." diff --git a/packages/content-engine/src/orchestrator.ts b/packages/content-engine/src/orchestrator.ts index ef13f60..30b3864 100644 --- a/packages/content-engine/src/orchestrator.ts +++ b/packages/content-engine/src/orchestrator.ts @@ -17,6 +17,7 @@ export interface OptimizationTask { availableComponents?: ComponentDefinition[]; instructions?: string; internalLinks?: { title: string; slug: string }[]; + customSources?: string[]; } export interface OptimizeFileOptions { @@ -211,7 +212,32 @@ export class AiBlogPostOrchestrator { console.log(`✅ Saved optimized file to: ${finalPath}`); } - private async generateVisualPrompt(content: string): Promise { + async generateSlug(content: string, title?: string, instructions?: string): Promise { + const response = await this.openai.chat.completions.create({ + model: "google/gemini-2.5-flash", + messages: [ + { + role: "system", + content: `You generate SEO-optimized URL slugs for B2B blog posts based on the provided content. +Return ONLY a JSON object with a single string field "slug". +Example: {"slug": "how-to-optimize-react-performance"} +Rules: Use lowercase letters, numbers, and hyphens only. No special characters. Keep it concise (2-5 words).`, + }, + { role: "user", content: `Title: ${title || "Unknown"}\n\nContent:\n${content.slice(0, 3000)}...${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the slug:\n${instructions}` : ""}` }, + ], + response_format: { type: "json_object" }, + }); + + try { + const parsed = JSON.parse(response.choices[0].message.content || '{"slug": ""}'); + let slug = parsed.slug || "new-post"; + return slug.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + } catch { + return "new-post"; + } + } + + public async generateVisualPrompt(content: string, instructions?: string): Promise { const response = await this.openai.chat.completions.create({ model: this.model, messages: [ @@ -227,7 +253,7 @@ FOCUS: The core metaphor or technical concept of the article. Example output: "A complex network of glowing fiber optic nodes forming a recursive pyramid structure, technical blue lineart style."`, }, - { role: "user", content: content.slice(0, 5000) }, + { role: "user", content: `${content.slice(0, 5000)}${instructions ? `\n\nEDITOR INSTRUCTIONS:\nPlease strictly follow these instructions from the editor when generating the visual prompt:\n${instructions}` : ""}` }, ], max_tokens: 100, }); @@ -303,6 +329,7 @@ Example output: "A complex network of glowing fiber optic nodes forming a recurs ); const realPosts = await this.researchAgent.fetchRealSocialPosts( task.content.slice(0, 500), + task.customSources ); socialPosts.push(...realPosts); } @@ -470,7 +497,6 @@ BLOG POST BEST PRACTICES (MANDATORY): - MEME DIVERSITY: Du MUSST ZWINGEND für jedes Meme (sofern passend) abwechslungsreiche Templates nutzen. Um dies zu garantieren, wurde für diesen Artikel das folgende Template ausgewählt: '${forcedMeme}'. Du MUSST EXAKT DIESES TEMPLATE NUTZEN. Versuche nicht, es durch ein Standard-Template wie 'drake' zu ersetzen! - Zitat-Varianten: Wenn du Organisationen oder Studien zitierst, nutze ArticleQuote (mit isCompany=true für Firmen). Für Personen lass isCompany weg. - Füge zwingend ein prägnantes 'TL;DR' ganz am Anfang ein. -- Füge ein sauberes TableOfContents ein. - Verwende unsere Komponenten stilvoll für Visualisierungen. - Agiere als hochprofessioneller Digital Architect und entferne alte MDX-Metadaten im Body. - Fazit: Schließe JEDEN Artikel ZWINGEND mit einem starken, klaren 'Fazit' ab. diff --git a/packages/image-processor/models/tiny_face_detector_model-shard1 b/packages/image-processor/models/tiny_face_detector_model-shard1 new file mode 100644 index 0000000..1becba2 --- /dev/null +++ b/packages/image-processor/models/tiny_face_detector_model-shard1 @@ -0,0 +1 @@ +404: Not Found \ No newline at end of file diff --git a/packages/image-processor/models/tiny_face_detector_model-weights_manifest.json b/packages/image-processor/models/tiny_face_detector_model-weights_manifest.json new file mode 100644 index 0000000..d11c9a3 --- /dev/null +++ b/packages/image-processor/models/tiny_face_detector_model-weights_manifest.json @@ -0,0 +1,30 @@ +[ + { + "weights": + [ + {"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}}, + {"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}}, + {"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}}, + {"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}}, + {"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}}, + {"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}}, + {"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}}, + {"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}}, + {"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}}, + {"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}}, + {"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}}, + {"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}}, + {"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}}, + {"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}}, + {"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}}, + {"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}}, + {"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}}, + {"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}}, + {"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}} + ], + "paths": + [ + "tiny_face_detector_model.bin" + ] + } +] \ No newline at end of file diff --git a/packages/image-processor/package.json b/packages/image-processor/package.json index e490a8c..6e814d0 100644 --- a/packages/image-processor/package.json +++ b/packages/image-processor/package.json @@ -13,11 +13,14 @@ } }, "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean", - "dev": "tsup src/index.ts --format esm --watch --dts", + "build": "tsup", + "dev": "tsup --watch", "lint": "eslint src" }, "dependencies": { + "@tensorflow/tfjs": "^4.22.0", + "@vladmandic/face-api": "^1.7.15", + "canvas": "^3.2.1", "sharp": "^0.33.2" }, "devDependencies": { @@ -27,4 +30,4 @@ "tsup": "^8.3.5", "typescript": "^5.0.0" } -} +} \ No newline at end of file diff --git a/packages/image-processor/src/processor.ts b/packages/image-processor/src/processor.ts index 09e134c..163b6aa 100644 --- a/packages/image-processor/src/processor.ts +++ b/packages/image-processor/src/processor.ts @@ -1,11 +1,40 @@ import sharp from "sharp"; +import { Canvas, Image, ImageData } from "canvas"; +// Use the ESM no-bundle build to avoid the default Node entrypoint +// which hardcodes require('@tensorflow/tfjs-node') and crashes in Docker. +// This build uses pure @tensorflow/tfjs (JS-only, no native C++ bindings). +// @ts-ignore - direct path import has no type declarations +import * as faceapi from "@vladmandic/face-api/dist/face-api.esm-nobundle.js"; +import * as tf from "@tensorflow/tfjs"; +import path from "path"; +import { fileURLToPath } from "url"; + +// Polyfill required by face-api for Node.js +faceapi.env.monkeyPatch({ Canvas, Image, ImageData } as any); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const MODEL_URL = path.join(__dirname, "../models"); + +// State flag to ensure we only load weights once +let modelsLoaded = false; + +async function loadModelsOnce() { + if (modelsLoaded) return; + // Initialize pure JS CPU backend (no native bindings needed) + await tf.setBackend("cpu"); + await tf.ready(); + + // Load the microscopic TinyFaceDetector (~190KB) + await faceapi.nets.tinyFaceDetector.loadFromDisk(MODEL_URL); + modelsLoaded = true; +} export interface ProcessImageOptions { width: number; height: number; format?: "webp" | "jpeg" | "png" | "avif"; quality?: number; - openRouterApiKey?: string; } /** @@ -80,91 +109,6 @@ export function parseImgproxyOptions( return options; } -interface FaceDetection { - x: number; - y: number; - width: number; - height: number; -} - -/** - * Detects faces using OpenRouter Vision API. - * Uses a small preview to save bandwidth and tokens. - */ -async function detectFacesWithCloud( - inputBuffer: Buffer, - apiKey: string, -): Promise { - try { - // Generate a small preview for vision API (max 512px) - const preview = await sharp(inputBuffer) - .resize(512, 512, { fit: "inside" }) - .jpeg({ quality: 60 }) - .toBuffer(); - - const base64Image = preview.toString("base64"); - - const response = await fetch( - "https://openrouter.ai/api/v1/chat/completions", - { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - "HTTP-Referer": "https://mintel.me", - "X-Title": "Mintel Image Service", - }, - body: JSON.stringify({ - model: "google/gemini-3-flash-preview", // Fast, cheap, and supports vision - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: 'Detect all human faces in this image. Return ONLY a JSON array of bounding boxes like: [{"x": 0.1, "y": 0.2, "width": 0.05, "height": 0.05}]. Coordinates must be normalized (0 to 1). If no faces, return [].', - }, - { - type: "image_url", - image_url: { - url: `data:image/jpeg;base64,${base64Image}`, - }, - }, - ], - }, - ], - response_format: { type: "json_object" }, - }), - }, - ); - - if (!response.ok) { - throw new Error(`OpenRouter API error: ${response.statusText}`); - } - - const data = (await response.json()) as any; - const content = data.choices[0]?.message?.content; - - if (!content) return []; - - // The model might return directly or wrapped in a json field - const parsed = typeof content === "string" ? JSON.parse(content) : content; - const detections = (parsed.faces || parsed.detections || parsed) as any[]; - - if (!Array.isArray(detections)) return []; - - return detections.map((d) => ({ - x: d.x, - y: d.y, - width: d.width, - height: d.height, - })); - } catch (error) { - console.error("Cloud face detection failed:", error); - return []; - } -} - export async function processImageWithSmartCrop( inputBuffer: Buffer, options: ProcessImageOptions, @@ -176,32 +120,41 @@ export async function processImageWithSmartCrop( throw new Error("Could not read image metadata"); } - const detections = options.openRouterApiKey - ? await detectFacesWithCloud(inputBuffer, options.openRouterApiKey) - : []; + // Load ML models (noop if already loaded) + await loadModelsOnce(); + + // Convert sharp image to a Node-compatible canvas Image for face-api + const jpegBuffer = await sharpImage.jpeg().toBuffer(); + const img = new Image(); + img.src = jpegBuffer; + const canvas = new Canvas(img.width, img.height); + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0, img.width, img.height); + + // Detect faces locally using the tiny model + // Requires explicit any cast since the types expect HTML elements in browser contexts + const detections = await faceapi.detectAllFaces( + canvas as any, + new faceapi.TinyFaceDetectorOptions(), + ); + + let cropPosition: "center" | "attention" | number = "attention"; // Fallback to sharp's attention if no faces - // If faces are found, calculate the bounding box containing all faces if (detections.length > 0) { - // Map normalized coordinates back to pixels - const pixelDetections = detections.map((d) => ({ - x: d.x * (metadata.width || 0), - y: d.y * (metadata.height || 0), - width: d.width * (metadata.width || 0), - height: d.height * (metadata.height || 0), - })); - + // We have faces! Calculate the bounding box that contains all of them let minX = metadata.width; let minY = metadata.height; let maxX = 0; let maxY = 0; - for (const det of pixelDetections) { - if (det.x < minX) minX = Math.max(0, det.x); - if (det.y < minY) minY = Math.max(0, det.y); - if (det.x + det.width > maxX) - maxX = Math.min(metadata.width, det.x + det.width); - if (det.y + det.height > maxY) - maxY = Math.min(metadata.height, det.y + det.height); + for (const det of detections) { + const box = det.box; + if (box.x < minX) minX = Math.max(0, box.x); + if (box.y < minY) minY = Math.max(0, box.y); + if (box.x + box.width > maxX) + maxX = Math.min(metadata.width, box.x + box.width); + if (box.y + box.height > maxY) + maxY = Math.min(metadata.height, box.y + box.height); } const centerX = Math.floor(minX + (maxX - minX) / 2); @@ -213,32 +166,39 @@ export async function processImageWithSmartCrop( let cropWidth = metadata.width; let cropHeight = metadata.height; + // Determine the maximal crop window that maintains aspect ratio if (currentRatio > targetRatio) { cropWidth = Math.floor(metadata.height * targetRatio); } else { cropHeight = Math.floor(metadata.width / targetRatio); } + // Center the crop window over the center of the faces let cropX = Math.floor(centerX - cropWidth / 2); let cropY = Math.floor(centerY - cropHeight / 2); + // Keep crop window inside image bounds if (cropX < 0) cropX = 0; if (cropY < 0) cropY = 0; if (cropX + cropWidth > metadata.width) cropX = metadata.width - cropWidth; if (cropY + cropHeight > metadata.height) cropY = metadata.height - cropHeight; + // Pre-crop the image to isolate the faces before resizing sharpImage.extract({ left: cropX, top: cropY, width: cropWidth, height: cropHeight, }); + + // As we manually calculated the exact focal box, we can now just center it + cropPosition = "center"; } let finalImage = sharpImage.resize(options.width, options.height, { fit: "cover", - position: detections.length > 0 ? "center" : "attention", + position: cropPosition, }); const format = options.format || "webp"; diff --git a/packages/image-processor/tsup.config.ts b/packages/image-processor/tsup.config.ts new file mode 100644 index 0000000..0f0a310 --- /dev/null +++ b/packages/image-processor/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + // Bundle face-api and tensorflow inline (they're pure JS). + // Keep sharp and canvas external (they have native C++ bindings). + noExternal: [ + "@vladmandic/face-api", + "@tensorflow/tfjs", + "@tensorflow/tfjs-backend-wasm" + ], + external: [ + "sharp", + "canvas" + ], +}); diff --git a/packages/journaling/src/agent.ts b/packages/journaling/src/agent.ts index 133baac..129cb7c 100644 --- a/packages/journaling/src/agent.ts +++ b/packages/journaling/src/agent.ts @@ -176,6 +176,7 @@ Return JSON: { "facts": [ { "statement": "...", "source": "Organization Name Onl */ async fetchRealSocialPosts( topic: string, + customSources?: string[], retries = 1, ): Promise { console.log( @@ -220,7 +221,7 @@ Return a JSON object with a single string field "query". Example: {"query": "cor if (!videos || videos.length === 0) { console.warn(`⚠️ [Serper] No videos found for query: "${queryStr}"`); - if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1); + if (retries > 0) return this.fetchRealSocialPosts(topic, customSources, retries - 1); return []; } @@ -237,11 +238,16 @@ Return a JSON object with a single string field "query". Example: {"query": "cor if (ytVideos.length === 0) { console.warn(`⚠️ [Serper] No YouTube videos in search results.`); - if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1); + if (retries > 0) return this.fetchRealSocialPosts(topic, customSources, retries - 1); return []; } // Step 3: Ask the LLM to evaluate the relevance of the found videos + + const sourceExamples = customSources && customSources.length > 0 + ? `Specifically prioritize content from: ${customSources.join(", ")}.` + : `(e.g., Google Developers, Vercel, Theo - t3.gg, Fireship, Syntax, ByteByteGo, IBM Technology, McKinsey, Gartner, Deloitte).`; + const evalPrompt = `You are a strict technical evaluator. You must select the MOST RELEVANT educational tech video from the list below based on this core article context: "${topic.slice(0, 800)}..." Videos: @@ -249,7 +255,7 @@ ${ytVideos.map((v, i) => `[ID: ${i}] Title: "${v.title}" | Channel: "${v.channel RULES: 1. The video MUST be highly relevant to the EXACT technical topic of the context. -2. The channel SHOULD be a high-quality tech, development, or professional B2B channel (e.g., Google Developers, Vercel, Theo - t3.gg, Fireship, Syntax, ByteByteGo, IBM Technology, McKinsey, Gartner, Deloitte). AVOID gaming, generic vlogs, clickbait, off-topic podcasts, or unrelated topics. +2. The channel SHOULD be a high-quality tech, development, or professional B2B channel ${sourceExamples} AVOID gaming, generic vlogs, clickbait, off-topic podcasts, or unrelated topics. 3. If none of the videos are strictly relevant to the core technical or business subject (e.g. they are just casually mentioning the word), YOU MUST RETURN -1. Be extremely critical. Do not just pick the "best of the worst". 4. If one is highly relevant, return its ID number. @@ -273,7 +279,7 @@ Return ONLY a JSON object: {"bestVideoId": number}`; if (bestIdx < 0 || bestIdx >= ytVideos.length) { console.warn(`⚠️ [Serper] LLM rejected all videos as irrelevant.`); - if (retries > 0) return this.fetchRealSocialPosts(topic, retries - 1); + if (retries > 0) return this.fetchRealSocialPosts(topic, customSources, retries - 1); return []; } @@ -342,7 +348,7 @@ CRITICAL: Do NOT provide more than 2 trendsKeywords. Keep it extremely focused.` try { let parsed = JSON.parse( response.choices[0].message.content || - '{"trendsKeywords": [], "dcVariables": []}', + '{"trendsKeywords": [], "dcVariables": []}', ); if (Array.isArray(parsed)) { parsed = parsed[0] || { trendsKeywords: [], dcVariables: [] }; diff --git a/packages/pdf-library/src/components/AgbsPDF.tsx b/packages/pdf-library/src/components/AgbsPDF.tsx index f64472b..df6b14c 100644 --- a/packages/pdf-library/src/components/AgbsPDF.tsx +++ b/packages/pdf-library/src/components/AgbsPDF.tsx @@ -13,6 +13,8 @@ import { Footer, FoldingMarks, DocumentTitle, + COLORS, + FONT_SIZES, } from "./pdf/SharedUI.js"; import { SimpleLayout } from "./pdf/SimpleLayout.js"; @@ -29,23 +31,23 @@ const localStyles = PDFStyleSheet.create({ marginBottom: 6, }, monoNumber: { - fontSize: 7, + fontSize: FONT_SIZES.TINY, fontWeight: "bold", - color: "#94a3b8", + color: COLORS.TEXT_LIGHT, letterSpacing: 2, width: 25, }, sectionTitle: { - fontSize: 9, + fontSize: FONT_SIZES.LABEL, fontWeight: "bold", - color: "#000000", + color: COLORS.CHARCOAL, textTransform: "uppercase", letterSpacing: 0.5, }, officialText: { - fontSize: 8, + fontSize: FONT_SIZES.BODY, lineHeight: 1.5, - color: "#334155", + color: COLORS.TEXT_MAIN, textAlign: "justify", paddingLeft: 25, }, @@ -100,7 +102,7 @@ export const AgbsPDF = ({ }; const content = ( - <> + Die Leistung gilt als abgenommen, wenn der Auftraggeber sie produktiv - nutzt oder innerhalb von 7 Tagen nach Bereitstellung keine + nutzt oder innerhalb von 30 Tagen nach Bereitstellung keine wesentlichen Mängel angezeigt werden. Optische Abweichungen, Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel dar. @@ -206,7 +208,7 @@ export const AgbsPDF = ({ bleibt die Wirksamkeit der übrigen Regelungen unberührt. - + ); if (mode === "full") { @@ -214,9 +216,8 @@ export const AgbsPDF = ({ {content} @@ -232,7 +233,7 @@ export const AgbsPDF = ({