xscriptor
Full design and development documentation for xscriptor.com, the Xscriptor literary portfolio (Next.js 16 App Router, static export, five locales, four book-reader engines, decryption animations). Use when building or modifying pages, components, CSS, content, i18n, book readers, or the build/deploy of xscriptor.com.
Xscriptor Site System
xscriptor.com is the personal literary portfolio, blog, book library and art
surface of the author/artist Xscriptor (Óscar Preciado). It is a fully
static, multilingual Next.js 16 App Router site whose signature interaction is
text that decrypts character by character, set in EB Garamond over full-bleed
art.
This skill documents the whole system — design language, architecture, routes, components, content pipeline, book readers, i18n, theming, SEO, security and deployment — so an agent can extend the site without breaking its voice or its conventions.
Repository: github.com/xscriptor-web/xscriptor.com (branch main). The Next
app lives at the repository root.
Companion references
Read the one(s) relevant to the task; they are the detailed source of truth.
| Reference | Covers |
|-----------|--------|
| references/design-system.md | Brand intent, color tokens, typography, surfaces (glass/neumorphism/marker chips), motion language, breakpoints, a11y |
| references/architecture.md | Stack, rendering model, route table, providers, i18n, theme, SEO, security, gotchas |
| references/component-catalog.md | Every component: location, props, behavior, recipes |
| references/content-pipeline.md | Blog article pipeline, book MDX format, poemParser, messages + WordConfig |
| references/book-readers.md | The four reader engines and their parsing/pagination algorithms |
| references/build-and-deploy.md | Scripts, static export, sitemap, .htaccess/_headers, hosting, validation |
| references/code-structure.md | Where code goes, responsibilities, import rules, anti-patterns, debt |
When to use this skill
- Adding or editing any page, section or route on xscriptor.com.
- Building or refactoring components, CSS modules, or global styles.
- Working on the book readers, blog pipeline, graph, contact/PGP page or home hero.
- Adding articles, books, translations or UI strings.
- Touching i18n, theming, SEO/metadata, static export or deployment config.
- Anything that must stay visually and structurally consistent with the site.
1. Design intent (the part that matters most)
The site must read like a printed literary edition that has learned to decrypt itself. Concretely:
- Typography-first. EB Garamond carries everything. No display font, no icon font. Words are the interface.
- Marker aesthetic. Text over imagery sits on soft
color-mixchips so it stays legible. - One accent per theme. Deep violet (
#5b2e8d) in light, warm gold (#ffe884) in dark. Everything else is background/text/border. - Atmospheric art. Full-viewport photographs and pre-rendered background frames behind a dark overlay; the art is the stage, never the subject.
- Motion reveals, it does not decorate. Decryption, blur-to-focus, staggered fades, a scroll-drawn connecting line.
- Literary + cryptographic tension. Poetic prose next to PGP fingerprints and descrambling text is the brand, not a gimmick.
- Both themes first-class; accessible by default; static by default.
If a proposed UI is clever but reads as generic SaaS, reject it. Choose clarity and voice over novelty.
2. Tech stack at a glance
| Layer | Choice |
|-------|--------|
| Framework | Next.js 16.3 App Router, output: "export", trailingSlash: true |
| Language | TypeScript 5 (strict), React 19.2, some JS |
| Styling | Tailwind CSS 4.3 (PostCSS) + CSS Modules |
| Font | Self-hosted EB Garamond (Regular + Italic, woff2) |
| Motion | framer-motion 12 |
| Smooth scroll | lenis 1.3 |
| Content | Markdown/MDX + gray-matter + remark/rehype (GFM, KaTeX, highlight) |
| Components | @xscriptor/xcomponents (npm) + local copies |
| Backgrounds | @xscriptor/xbackgrounds (XParticles) |
| Sitemap | next-sitemap on postbuild |
| Hosting | Static (Apache .htaccess, or _headers platforms) |
No server runtime, no API routes, no PHP, no env vars. Details in
architecture.md §1.
3. Rendering model in one minute
- Everything is pre-rendered.
page.tsxfiles are Server Components; book pages read MDX from disk at build time and pass raw text to client readers. - Interactive code is
"use client", including the"use client"barrel atsrc/app/components/xcomponents/index.ts. Server Components must import reusable components only via that barrel. - Two locales layers: the root layout hardcodes
es(so/is Spanish); the[locale]layout mounts a nestedI18nProviderwith the requested locale. - Providers wrap the app in the root layout:
LenisProvider(smooth scroll),TransitionProvider(glass navbar + language dock + route overlay), then separators,main, footer. - Theming uses
data-themeon<html>+localStorage["theme"], applied before first paint by abeforeInteractivescript and toggled by the navbar logo.
Full detail: architecture.md §2–§7.
4. Route map (summary)
All pages live under [locale] (es, en, de, it, fr; es fallback):
| Path | What |
|------|------|
| / and /[locale] | Home hero (ASCII intro → zigzag phrases) |
| /[locale]/blog | Article listing (search + category pills, center-blur cards) |
| /[locale]/blog/[...slug] | Article (markdown → KaTeX/highlight → decrypt) |
| /[locale]/obras | Book gallery (center-blur cards + graph card) |
| /[locale]/obras/{boulevard,asintota,cielos-de-alquitran,colaterales,primavera-en-el-desierto,la-danza-de-las-amapolas} | Book readers |
| /[locale]/obras/grafo | Canvas poem graph |
| /[locale]/contacto | PGP public-key card + social grid |
| /[locale]/info | Bio, press cards, timeline |
| /[locale]/links | Link list |
| /[locale]/terminos-y-condiciones | Terms/privacy/cookies |
| 404 | src/app/not-found.tsx |
Exact files and data sources: architecture.md §3.
5. The design system (essentials)
Full token tables and rules in design-system.md. The non-negotiables:
Colors — use the variables, never raw hex
Light: --bg #ffffff, --text #000000, --accent #5b2e8d,
--accent-text #ffffff, --border rgba(0,0,0,.1), --foreground #171717,
--text-muted #6b7280, --primary #4328a8, --primary-hover #7c3aed,
--success #10b981.
Dark (:root[data-theme="dark"], :root.dark): --bg #0a0a0a, --text #ffffff,
--accent #ffe884, --accent-text #000000, --border rgba(255,255,255,.1),
--foreground #ededed, --text-muted #9ca3af, --primary #fbbf24,
--primary-hover #f59e0b, --success #34d399.
Only sanctioned raw hexes: the book word palette (c1–c5) and the graph’s
8-color palette.
Type
EB Garamond everywhere. Global centered defaults: h1 400/clamp(16px,12vw,32px),
h2 300/clamp(24px,8vw,26px), h3 600/clamp(20px,9vw,26px),
p 400/clamp(18px,2.5vw,20px) with side padding clamp(1rem,8vw,4rem).
.article-content resets prose to left-aligned, line-height 1.8. Monospace is
only for code/keys/meta.
Marker chip (the signature atom)
background: color-mix(in srgb, var(--bg) 88%, transparent);
padding: 0.1em 0.35em; border-radius: 0.2em;
box-decoration-break: clone; -webkit-box-decoration-break: clone;
Glass
color-mix(in srgb, var(--bg) 70%, transparent) (light) / 15% (dark) +
backdrop-filter: blur(18–20px) + 1px solid var(--border).
Neumorphism
Only PublicKeyCard, via --neumorph-bg and :root.light / :root.dark.
Motion
Subtle, brief, purposeful; reveal not decorate. Respect
prefers-reduced-motion (global rule + component guards). Decrypt speed default
50 ms (article body 10 ms). See design-system.md §6.
6. The book reader engines (choose deliberately)
Four engines share the block/section/index/4-5-per-page model but differ:
| Engine | Books | Signature |
|--------|-------|-----------|
| XBookReader (npm) | Boulevard | simplest paginated reader |
| XCompleteBook (npm, via barrel) | Asintota, Amapolas | section/index pages + renderPoem override |
| XBookColors (local) | Cielos de Alquitrán, Primavera, Amapolas page | rotating full-bleed backgrounds + per-word colors/decrypt |
| BookReaderPoems (local) | Colaterales | index-driven poem reordering + titles |
Poem words become WordConfig[] with hashed underline/button/blur1/
blur2/normal types plus a safety fallback so blurred words are always
revealable. Deep dive, parsing algorithms and pitfalls: book-readers.md.
7. Content & i18n essentials
- Blog:
src/app/content/articulos/{locale}/**/*.md; parsed bysrc/app/lib/articles.ts(frontmatter → markdown → GFM/KaTeX/highlight → HTML; duplicate H1/author stripped; soft lines →<br>; tables wrapped;readingTime = ceil(words/200));esfallback;research/excluded from listings. - Books:
src/app/content/<book>/{locale}.mdx, read as raw text (cielos-de-alquitranES file iscielos-de-alquitran.mdx). - UI strings:
messages/{locale}.json, namespaced; consume withuseT(namespace)(client) orgetMsg(server); add keys to all five locales. HomePhrasesholdsWordConfig[]arrays consumed byXHomeColors.- Graph:
src/lib/poemParser.tsaggregates all books + flat blog articles. - Full contracts and checklists:
content-pipeline.md.
8. Components (quick index)
Barrel import surface: import { ... } from "@/app/components/xcomponents".
- npm:
XNavbar,XFooter,XSeparator,XZigZagLayout,XBookReader,XBookReaderIllus,XInteractivePhrase,XDecryptedText,XCompleteBook,XBookFullDecrypt,XContactForm,XNewsletter,XSocialContact. - local reusable:
XGlassNavbar,XZigZagLayoutVideo,XHomeColors,XBookColors,BookReaderPoems,XMinimalFooter. - app-level:
ClientComponentHome,AsciiLoadingAnimation,LenisProvider,transitionProvider,ConditionalSeparator,XFooterComponent,PublicKeyCard,SocialGrid,ParticlesBackground,BlogListClient,ObrasClientPage,XBlogDecrypt,XTextDecrypt,GrafoPoetico, reader wrappers (AsintotaBook,CielosDeAlquitranBook,AmapolasBook),navbarIcons.
Props, behavior and recipes: component-catalog.md.
XContactForm,XNewsletterandXSocialContactare re-exported but not rendered by any page today; there is no newsletter backend. Don’t assume they are wired up.
9. Build & deployment
npm install
npm run dev # http://localhost:3000
npx tsc --noEmit # authoritative type check (next lint is flaky on Next 16)
npm run build # next build → out/ + postbuild next-sitemap
- Static export to
out/;robots.txt+ sitemaps are generated. - Security headers live in
public/.htaccess(Apache: HSTS, CSP, nosniff, frame/referrer/permissions, sensitive-file block, cache, deflate) andpublic/_headers(subset for_headersplatforms).'unsafe-inline'is required by the static export. public/x-public.ascis fetched at runtime byPublicKeyCard.- Background frame counts must match each reader’s
TOTAL_BG(asintota 72, amapolas 31, cielos 30).
Details and failure modes: build-and-deploy.md.
10. Conventions & guardrails
- Use tokens, not raw colors (except the documented palettes).
- Text over art goes on a marker chip.
- Import reusable components via the
"use client"barrel. Never import@xscriptor/xcomponentsdirectly in a Server Component. - Never fork npm components locally — update
@xscriptor/xcomponentsand bump the version. Re-apply the patches indocs/xcomponents-fixes.mdif you upgrade. - Pages are thin server components; interactivity lives in client components.
- Copy goes in
messages/*.jsonfor all five locales (plus the documented in-component dictionaries where they exist). - Add keys to every locale and, for content, add the same slug across locale folders.
- Respect
prefers-reduced-motionin any new animation. - Use
clamp()for fluid padding; avoid barevw. - No runtime/server features (route handlers, middleware, revalidate).
- Validate before finalizing:
npx tsc --noEmitthennpm run build. - Don’t commit secrets (there are none by design).
Known gotchas (see architecture.md §11 / code-structure.md §6)
- Locale list is duplicated in ~9 files; the footer and 404 only handle
en|es|deand fall back to Spanish forit/fr. - Two UI-text systems (messages vs in-component dictionaries) must be kept in sync.
- A local unreferenced
xcompletebook/XCompleteBook.tsxduplicate exists. gsapandreact-intersection-observerare installed but unused in app code.- Global
a:hover { color:#ff4141 }fights accent links. .htaccessblocks direct.json/.mdserving (fine for this build).
11. Editing recipe (agent checklist)
- Locate the route entry and its client component; read the matching
reference (
component-catalog.md,book-readers.md, …). - Plan against the design system: tokens, marker chips, motion budget.
- Implement at the right layer (route vs reusable vs lib vs content vs messages).
- Localize every string across the five locales; add content slugs in all five folders.
- Verify light/dark,
<768px, and reduced-motion. - Type-check with
npx tsc --noEmit, then build withnpm run build. - Report what changed and any structural debt you encountered.
12. Output expectations
A correct change to this site is:
- structurally consistent with the existing layers and import rules,
- visually quiet, typography-driven, on the marker/glass system,
- correct in light and dark themes and on mobile,
- localized in all five languages,
- accessible (sr-only text for decrypt animations, focus states, reduced motion),
- static-export-safe,
- and easy for a human to maintain afterwards.
If a solution is clever but harder to keep consistent with the above, reject it.
References (7)
architecture
Xscriptor Architecture
Technical map of xscriptor.com: stack, rendering model, route table, providers,
i18n, theming, SEO and security. Read alongside code-structure.md (where code
goes) and component-catalog.md (what each component does).
Repository: github.com/xscriptor-web/xscriptor.com (branch main, single
history). The Next.js app lives at the repository root.
1. Tech stack
| Layer | Choice | Notes |
|-------|--------|-------|
| Framework | Next.js 16.3.x (App Router) | output: "export", trailingSlash: true |
| Language | TypeScript 5 + some JS | strict: true, path alias @/* → ./src/* |
| UI runtime | React 19.2 | Server Components by default |
| Styling | Tailwind CSS 4.3 (PostCSS plugin) + CSS Modules | global CSS via @import "tailwindcss" |
| Fonts | EB Garamond (self-hosted woff2) | Regular + Italic |
| Motion | framer-motion 12 | route transition, modals, spans |
| Scroll | lenis 1.3 | smooth scrolling (LenisProvider) |
| Content | Markdown/MDX + gray-matter + remark/rehype | GFM, KaTeX, highlight |
| Markdown stack | remark-parse, remark-gfm, remark-math, remark-rehype, rehype-katex, rehype-highlight, rehype-stringify, unified, katex | in src/app/lib/articles.ts |
| Components | @xscriptor/xcomponents 0.2.3 (npm) + local copies | see component-catalog.md |
| Backgrounds | @xscriptor/xbackgrounds 0.1.0 | XParticles |
| QR | qrcode 1.5 | PGP fingerprint QR |
| Sitemap | next-sitemap 4.2 | runs on postbuild |
| Hosting | Static host (Apache .htaccess, plus _headers) | no server runtime |
react-intersection-observer and gsap are declared as dependencies. The app
code uses raw IntersectionObserver and requestAnimationFrame almost
everywhere; treat those two packages as candidates for removal unless a
component explicitly imports them (grep before deleting).
tailwind.config.* does not exist. Tailwind v4 is configured entirely via
postcss.config.mjs (plugins: ["@tailwindcss/postcss"]) and the
@import "tailwindcss" line in globals.css. @theme is not used — theming is
done with plain CSS custom properties.
2. Rendering model
-
Fully static export.
next.config.ts:const nextConfig: NextConfig = { output: "export", trailingSlash: true, images: { unoptimized: true }, };There is no Node runtime in production. Every route must be pre-renderable.
-
Server/Client boundary.
page.tsxfiles are Server Components. Book pages read MDX from disk at build time (fs.readFileSync) and pass raw text to a client reader.- Interactive components are marked
"use client"(readers, navbar, home, contact, info, blog list, transitions, etc.). - Root and
[locale]layouts are server components that mount client providers (I18nProvider,TransitionProvider,LenisProvider).
-
The
"use client"barrel. Local reusable components are re-exported fromsrc/app/components/xcomponents/index.ts, which starts with"use client". This is the boundary server components import from (see §6). -
generateStaticParams. The[locale]layout returns the five locales; book/article routes additionally enumerate their own params. -
Data at build time.
src/app/lib/articles.tsandsrc/lib/poemParser.tsusefs, so they can only run inside Server Components / build.
3. Route map
All user-facing pages live under the dynamic [locale] segment (no separate
non-localized route tree exists in this repo).
| Route | File | Rendering | Data source |
|-------|------|-----------|-------------|
| / | src/app/page.tsx | Static (locale = es) | delegates to ClientComponentHome |
| /[locale] | [locale]/page.tsx | Static per locale | ClientComponentHome |
| /[locale]/blog | [locale]/blog/page.tsx | Static per locale | getSortedArticles(locale) → BlogListClient |
| /[locale]/blog/[...slug] | [locale]/blog/[...slug]/page.tsx | Static per locale + slug | getArticleData(slug, locale) → XBlogDecrypt |
| /[locale]/contacto | [locale]/contacto/page.tsx | Static | ContactoClientPage → PublicKeyCard |
| /[locale]/info | [locale]/info/page.tsx | Static | InfoClientPage (press cards, timeline) |
| /[locale]/links | [locale]/links/page.tsx | Static (server component) | inline LINKS + LinksPage messages |
| /[locale]/obras | [locale]/obras/page.tsx | Static | ObrasClientPage (book gallery) |
| /[locale]/obras/boulevard | .../boulevard/page.tsx | Static | MDX boulevard/{locale}.mdx → npm XBookReader |
| /[locale]/obras/asintota | .../asintota/page.tsx | Static | MDX asintota/{locale}.mdx → AsintotaBook → npm XCompleteBook |
| /[locale]/obras/cielos-de-alquitran | .../cielos-de-alquitran/page.tsx | Static | MDX cielos-de-alquitran/{locale\|cielos-de-alquitran}.mdx → local XBookColors |
| /[locale]/obras/colaterales | .../colaterales/page.tsx | Static | MDX → local BookReaderPoems |
| /[locale]/obras/primavera-en-el-desierto | .../primavera-en-el-desierto/page.tsx | Static | MDX → local XBookColors |
| /[locale]/obras/la-danza-de-las-amapolas | .../la-danza-de-las-amapolas/page.tsx | Static | MDX → local XBookColors (page) / AmapolasBook → npm XCompleteBook with renderPoem |
| /[locale]/obras/grafo | .../obras/grafo/page.tsx | Static | getAllPoems(locale) → GrafoPoetico |
| /[locale]/terminos-y-condiciones | .../terminos-y-condiciones/page.tsx | Static | TermsClientPage |
| * (404) | src/app/not-found.tsx | Client | usePathname() → locale-specific copy |
There is no top-level
/blog,/obras,/contacto, etc. Any legacy reference toapp/blog/page.tsxroutes is stale.src/app/blog/andsrc/app/obras/currently hold only CSS modules consumed by the[locale]pages;src/app/contacto/andsrc/app/info/hold only their*.module.css.
Hero sequences
- Home (
ClientComponentHome): a timed ASCII intro overlay (AsciiLoadingAnimation, ~1.2 s minimum, frames auto-advance every 35 ms, hard cap 4 s), then twoXZigZagLayoutVideoblocks — a left-start block with sixXHomeColorsphrases and a right-start block with eight more. Background images alternate per block; a scroll-drawn SVG line connects the markers. - Obras / Blog galleries: full-viewport stacked sections that blur/dim the
non-centered item, based on a scroll listener computing the section nearest
the viewport center (
activeIndex). - Readers: full-viewport background frames plus a paginated content column.
4. Layout shell & providers
src/app/layout.tsx (server) is the root:
<html lang="es" suppressHydrationWarning>
<head>
<Script id="theme-init" strategy="beforeInteractive">
{`(function(){try{var t=localStorage.getItem("theme");if(t==="dark"){document.documentElement.setAttribute("data-theme","dark")}}catch(e){}})();`}
</Script>
</head>
<body className="min-h-screen flex flex-col bg-(--bg) text-(--text) overflow-x-hidden">
<a href="#main-content" className="skip-to-content">Saltar al contenido principal</a>
<I18nProvider locale="es" messages={esMessages}>
<LenisProvider />
<TransitionProvider />
<ConditionalSeparator />
<main id="main-content" className="px-4 sm:px-6 lg:px-8 pt-16">{children}</main>
<ConditionalSeparator />
<SiteXFooter />
</I18nProvider>
</body>
</html>
Key points:
- Root hardcodes Spanish (so
/is Spanish). The[locale]layout mounts a nestedI18nProviderwith the requested locale; the inner one wins for that subtree. import "@xscriptor/xcomponents/styles.css"is loaded in the root layout — the npm package's component styles ship there.- Global chrome order: smooth-scroll provider → route-transition/navbar/language
dock → conditional separator →
main→ separator → footer. mainhaspt-16to clear the fixed navbar and horizontal responsive padding.- No
<head>font preload tags; fonts are declared inglobals.css.
[locale]/layout.tsx:
generateStaticParams()→["es","en","de","it","fr"].generateMetadata()builds<link rel="alternate">hreflang tags and a canonical URL (https://xscriptor.comfores,.../{locale}otherwise).- Picks messages from the five imported JSON files, validates the locale
(
esfallback), mountsI18nProvider+UpdateHtmlLang.
5. i18n system
Five locales: es, en, de, it, fr. Spanish is the fallback.
Message store
messages/{locale}.json — 926 lines each, identical key shape. Namespaces:
Layout, Navbar, Footer, HomePage, BlogPage, ObrasPage,
LiteraturaPage, Books, ContactPage, ContactForm, Newsletter,
BookReader, InfoPage, LinksPage, TermsPage, HomePhrases,
UnderConstruction.
HomePhrases is special: it holds WordConfig[] arrays (not strings) consumed
by XHomeColors (see content-pipeline.md).
Client runtime — src/app/i18n-provider.tsx
I18nProvider({ locale, messages })stores{ locale, messages }in React Context.useLocale()→ currentLocale, defaults to"es"when outside a provider.useT(namespace?)→t(key, params?):- resolves dot-paths (
resolvePath) against the namespace, - interpolates
{param}tokens, - returns the key itself if a lookup misses,
- has
t.raw<T>(key)for arrays/objects (used forpressCards,timeline,HomePhrases).
- resolves dot-paths (
- The
Localetype is a strict union; adding a locale requires updating it here and in every duplicate locale list across the app (see gotchas in §9).
Server helper — src/app/lib/i18n-utils.ts
getMsg(messages, "Namespace.key") — a non-hook dot-path resolver used by
Server Components for generateMetadata/metadata. It returns the key on miss
(no interpolation).
Content localization
- Blog articles:
src/app/content/articulos/{locale}/**/*.md, withesfallback handled inarticles.ts. - Books:
src/app/content/{book}/{locale}.mdx(exception:cielos-de-alquitranES file iscielos-de-alquitran.mdx— seepoemParser.ts/page code). - Pages read their locale via
paramsand fall back toeswhen a file or locale is missing.
Nav chrome i18n
TransitionProvider keeps its own per-locale label dictionaries
(LOCALE_KEYS, NAV_LABELS, langLabels) rather than reading messages/. This
is a deliberate (but duplicated) convenience — see component-catalog.md and
the gotchas below.
6. Component system overview
Two tiers:
@xscriptor/xcomponents(npm) — the published library (styles.css,contenttypes). Imported through the local barrel.- Local components — either bespoke (
XGlassNavbar,XBookColors,XHomeColors,BookReaderPoems,XZigZagLayoutVideo,XMinimalFooter) or app-level (XBlogDecrypt,XTextDecrypt,GrafoPoetico,PublicKeyCard,SocialGrid,ParticlesBackground,AsciiLoadingAnimation,ClientComponentHome,LenisProvider,TransitionProvider,ConditionalSeparator,XFooterComponent, navbar icons).
The barrel src/app/components/xcomponents/index.ts is the import surface:
"use client";
export { XNavbar } from "@xscriptor/xcomponents";
export { XGlassNavbar } from "./xglassnavbar";
export { XZigZagLayoutVideo } from "./xzigzagvideo";
export { XFooter, XSeparator, XZigZagLayout } from "@xscriptor/xcomponents";
export { XBookReader, XBookReaderIllus, XInteractivePhrase, XDecryptedText, XCompleteBook, XBookFullDecrypt } from "@xscriptor/xcomponents";
export { default as XMinimalFooter } from "./xminimalfooter/XMinimalFooter";
export { XBookColors } from "./xbookcolors";
export { XHomeColors } from "./xhomecolors";
export { BookReaderPoems } from "./BookReaderPoems";
export { XContactForm, XNewsletter, XSocialContact } from "@xscriptor/xcomponents";
Why the barrel exists: the npm bundle's chunk-*.mjs does not preserve the
"use client" directive, so Server Components that fs.readFileSync (book
pages) would crash with useState is not a function when importing client-hook
components directly. The barrel re-adds the boundary.
Install docs and patches:
docs/STRUCTURE.md— high-level structure (Spanish).docs/xcomponents-fixes.md— required reading before upgrading@xscriptor/xcomponents; documents the two localXGlassNavbarpatches (active-link detection, theme persistence) and why they exist.
7. Theme system
- Attribute
data-themeon<html>; values"dark"(attribute present) or light (attribute absent).:root.dark/:root.lightclasses coexist for component CSS that keys off classes. - Persisted in
localStorageunder the keytheme("dark"|"light"). - Before first paint: a
next/scriptbeforeInteractivesnippet in the root layout readslocalStorageand setsdata-theme="dark"to avoid a flash. - Toggle: the navbar logo (center) is the theme button (
XGlassNavbarlogoAsThemeToggle), with sun/moon icons fromnavbarIcons.tsx. Mobile has a dedicated theme row in the overlay menu. - Hydration-safe sync: the navbar renders the same default on server and
client, reads
localStoragein a post-hydrationuseEffect, and gates persistence behind athemeReadyflag so the default light value never overwrites a stored"dark". (<html suppressHydrationWarning>absorbs the attribute difference.) ParticlesBackgroundmirrorsdata-themetodark/lightclasses on<html>soPublicKeyCard's:root.light/:root.darkneumorphism responds.- Components that need live theme reads use a
MutationObserverondocumentElementfiltered todata-theme(useDetectThemeinXHomeColors,XBookColors,GrafoPoetico). - Global theme transition:
background/color 0.3s easeonhtml, body.
8. SEO & metadata
- Root
metadataprovidesmetadataBase: https://xscriptor.com, a title template%s | Xscriptor, description, OpenGraph (es_ES, 1200×1200/xscriptor-signature.png), Twittersummary_large_image, robotsindex,follow. - Each page exports
generateMetadata(ormetadata) and sets a per-pageopenGraph.localevia the same inline ternaryes_ES/en_US/de_DE/it_IT/fr_FR. [locale]/layout.tsxadds canonical + hreflang alternates for the five locales.next-sitemap.config.js:siteUrl: https://xscriptor.com,generateRobotsTxt: true,sitemapSize: 10000,exclude: ['/api/*', '/blog/post'](the excludes are legacy but harmless).robots.txtandsitemap.xml/sitemap-0.xmlare generated by thepostbuildstep — never hand-author them inpublic/.- Icons:
src/app/favicon.icoandsrc/app/icon.svguse Next's file conventions.public/xscriptor-favicon.pngandxscriptor-signature.*are static assets referenced by metadata/UI.
9. Security & hosting headers
Two header sources ship in public/:
public/.htaccess(Apache — primary production target):- Static-export rewrite: clean URLs →
$1.html, unknown paths →index.html. - Headers: HSTS (
max-age=31536000; includeSubDomains; preload),X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy(camera/microphone/geolocation/interest-cohort all()), and a CSP:default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; media-src 'self'; connect-src 'self'; frame-ancestors 'self'; form-action 'self'. - Blocks direct access to
.env|.git|.json|.lock|.md|.mdxfiles. - Cache-Control via
mod_expires(media/fonts 1 year, css/js 1 month) andmod_deflate.
- Static-export rewrite: clean URLs →
public/_headers(Netlify/Cloudflare-Pages style): a subset (nosniff,X-Frame-Options: DENY, referrer policy, permissions policy).
'unsafe-inline'in the CSP is required by Next.js static export. A hash/nonce CSP would require server rendering, which this site does not have.
There is no PHP endpoint, no API route, and no newsletter backend in the
current repository. public/x-public.asc is a static PGP public key fetched by
fetch("/x-public.asc").
10. Environment variables
None. The project runs with zero env configuration: all URLs are hardcoded
(https://xscriptor.com), all data is on disk, all external contact channels
are static links (mailto:, Telegram, WhatsApp, social).
11. Known architectural gotchas
- Duplicated locale lists. The five-locale set is re-declared in
i18n-provider.tsx,articles.ts,poemParser.ts,[locale]/layout.tsx,transitionProvider.tsx,ConditionalSeparator.tsx,not-found.tsx,XFooterComponent.tsx, andGrafoPoeticolabel maps. Adding/removing a locale is a multi-file change. - Inconsistent locale regex.
TransitionProviderandConditionalSeparatormatch all five locales;XFooterComponentandnot-found.tsxuse/^\/(en|es|de)/and therefore fall back toesfor Italian and French paths. Fix both when touching footer/404 locale detection. - Two UI text systems.
messages/*.json(namespaced, provider-based) and hardcoded per-locale dictionaries inside components (TransitionProvider,GrafoPoetico, reader section names,not-found). Keep both in sync. - Unused local duplicate.
src/app/components/xcomponents/xcompletebook/XCompleteBook.tsxis a local implementation, but the barrel exports the npmXCompleteBook. The local file is currently unreferenced — confirm before editing or deleting. gsap/react-intersection-observerare installed but not imported in app code; candidates for dependency cleanup..htaccessblocks.json/.mdserving. Does not affect the built site (data is inlined into JS/HTML), but do not plan to serve raw content files.- Path aliases. Use
@/app/...and@/lib/...(mapped to./src/*). Relative imports with deep../../../../messages/...also appear in pages — both styles exist; prefer@/. next lintis unreliable on Next 16; validate types withnpx tsc --noEmit(seebuild-and-deploy.md).
book-readers
Xscriptor Book Reader Engines
The site has four different book-rendering engines. They share concepts (blank-line blocks, section titles, index, 4/5-item pagination, full-bleed backgrounds, decrypting words) but differ in parsing detail and interaction. Pick the engine deliberately; do not rewrite one into another.
| Engine | Origin | Used by | Signature |
|--------|--------|---------|-----------|
| XBookReader | npm @xscriptor/xcomponents | Boulevard | simplest paginated reader |
| XCompleteBook | npm @xscriptor/xcomponents | Asintota, La danza de las amapolas | section/index pages + renderPoem override |
| XBookColors | local xcomponents/xbookcolors | Cielos de Alquitrán, Primavera | per-block background rotation + per-word colors/decrypt |
| BookReaderPoems | local xcomponents/BookReaderPoems | Colaterales | index-driven poem reordering + titles |
There is also an unused local copy at
xcomponents/xcompletebook/XCompleteBook.tsx. The barrel exports the npmXCompleteBook, so the local file is dead code today. Verify before editing.
1. Shared content model
All engines consume the raw MDX string and split it into blocks on blank lines (2+ or 3+ newlines depending on the engine). Blocks become typed items:
type PageItem = {
type: "section_title" | "content" | "poem_block" | "image" | "index";
content: string;
title?: string; // poem_block (BookReaderPoems)
entries?: IndexEntry[];// index
alt?: string; src?: string; // image
};
- Section title — first line matches the engine’s
SECTION_NAMES[locale](case-insensitive). Each becomes its own page. - Image — block exactly
. - Index — trailing block beginning with the locale index header
(
Índice/Index/Inhalt/Indice/Index). Stripped from the body and rendered as a dedicated page; entry lines containing....123or- 123are poem entries, others are section names. - Content / poem — everything else.
Pagination grouping (identical in all engines)
function groupIntoPages(items, withCover) {
const pages = [];
if (withCover) pages.push([]); // page 0 = cover
let page = [];
for (const item of items) {
if (item.type === "section_title" || item.type === "index") {
if (page.length) { pages.push(page); page = []; }
pages.push([item]); // section/index always alone
continue;
}
page.push(item);
const maxPerPage = pages.length % 2 === 0 ? 4 : 5; // alternate 4 / 5
if (page.length >= maxPerPage) { pages.push(page); page = []; }
}
if (page.length) pages.push(page);
return pages;
}
Cover page (currentPage === 0 && coverImage) shows just the cover image;
section pages show a centered title; index pages show the index list; content
pages render a zigzag of blocks. Navigation scrolls the reader into view with
scrollIntoView({ behavior: "smooth", block: "start" }).
Default labels (per locale)
DEFAULT_LABELS[locale] = { prev, next, pageOf: "Página {current} de {total}", index }
(Previous/Next, Zurück/Weiter, Precedente/Successivo,
Précédent/Suivant). {current} and {total} are manually split around the
page-number input, not interpolated by a formatter. BookReaderPoems instead
pulls labels from useT("BookReader").
Word parsing (for decrypting poem text)
Every engine that renders poems converts each poem into WordConfig[]:
// split into lines → words → chunks of (1 + word.length % 3) words
const hash = chunkText.length + chunkIndex * 3 + lineIndex * 5;
if (hash % 7 === 0) type = "underline";
else if (hash % 11 === 0) type = "button";
else if (hash % 13 === 0) type = "blur1";
else if (hash % 17 === 0) type = "blur2";
italic = hash % 4 === 0;
bold = hash % 9 === 0;
breakAfter = last chunk in line;
Then a safety pass guarantees at least one underline, button, blur1,
blur2 exist (otherwise blurred words could never be revealed). For very short
poems (≤3 chunks) the assignment is explicit:
| chunks | distribution |
|--------|--------------|
| 1 | underline |
| 2 | underline + blur1 |
| 3 | underline + blur1 + button |
XBookColors (XBookFullDecrypt) and XCompleteBook implement this safety pass;
BookReaderPoems overrides the last normal as button if none exists.
2. XBookColors (local) — deep dive
File: src/app/components/xcomponents/xbookcolors/XBookColors.tsx (+ .module.css).
Props
{
rawText: string;
coverImage?: string;
backgroundImage?: string;
backgroundVideo?: string;
bgConfig?: { basePath: string; total: number; digits?: number; format?: string };
locale?: "es"|"en"|"de"|"it"|"fr";
overlayColor?: string; // default rgba(0,0,0,0.45)
labels?: { prev?; next?; pageOf?; index? };
onPageChange?: (page: number) => void;
sectionNames?: Record<Locale, string[]>;
}
Backgrounds
resolvedBg = bgConfig ? ${basePath}${pad((bgIndex % total) + 1, digits ?? 2)}.${format ?? "webp"} : backgroundImage.
bgIndex is set to the current page on navigation, so backgrounds rotate in
lockstep with pages. The wrapper fixed-bleeds a <video> or <img> plus a
bgOverlay tint.
Rendering
- Cover page, section-title page, index page exactly as the shared model.
- Content pages:
XZigZagLayout(startSide="left",gap 6,offset clamp(1rem,4vw,4rem),textAlign="side",showLine,lineColor = wordHex[BLOCK_COLORS[currentPage % 5]],lineThickness 0.5) with each item either an image block or aXBookFullDecryptpoem block. - Pagination: SVG chevrons, split
pageOftemplate + numeric input (digits-only, Enter/blur to jump, resets out-of-range).
Theme + colors
useDetectTheme (MutationObserver on data-theme) selects DARK_WORD_HEX /
LIGHT_WORD_HEX and DARK_COLORS / LIGHT_COLORS (sets --c0…--c7), applied
as inline style on the wrapper. XBookFullDecrypt cycles WORD_COLORS every
2 chunks and applies color + --c-word inline; blur is applied via inline
styles (filter: blur(10px); opacity:.3) because CSS-class swapping did not
reliably win the cascade (documented in xbookcolors/README.md).
Wrappers that rotate backgrounds
CielosDeAlquitranBook and AmapolasBook are client wrappers that keep their
own bgIndex, compute the next frame, and preload it with new Image() before
passing backgroundImage to XCompleteBook. Note the page routes for
Cielos/Primavera/Amapolas use XBookColors directly with bgConfig when a
background set exists (Amapolas page uses XBookColors + bgConfig; the
AmapolasBook wrapper is a separate XCompleteBook variant — inspect the route
before editing to know which is live).
3. XCompleteBook (npm, via barrel) — deep dive
File: npm @xscriptor/xcomponents; local duplicate at
xcomponents/xcompletebook/XCompleteBook.tsx mirrors the logic.
Props
{
rawText: string;
coverImage?: string;
backgroundImage?: string;
backgroundVideo?: string;
locale?: "es"|"en"|"de"|"it"|"fr";
overlayColor?: string;
labels?: { prev?; next?; pageOf?; index? };
onPageChange?: (page: number) => void;
sectionNames?: string[]; // for THIS book, current locale
renderPoem?: (content: string) => ReactNode; // replaces XInteractivePhrase
}
Differences from XBookColors
- Content pages use
XZigZagLayoutwithlineColor="var(--accent)",lineThickness 0.2, and each poem is rendered byXInteractivePhrase(npm) unlessrenderPoemis supplied. - Default section names are geometry-themed (
CURVA, PLANO CARTESIANO, RECTA, I…V);AmapolasBookoverrides them with the poppy sections. - No per-block background rotation; the wrapper passes a single rotating
backgroundImage.
Usage
- Asintota:
AsintotaBookrotates 72 backgrounds (bgConfigequivalent) and passes per-locale labels;renderPoemnot set (usesXInteractivePhrase). - Amapolas wrapper: passes
sectionNamesandrenderPoem={(c) => <XBookFullDecrypt content={c} />}to get the colored per-word decrypt. (The Amapolas route currently usesXBookColorswithbgConfiginstead — confirm which is active before changing.)
4. BookReaderPoems (local) — deep dive
File: src/app/components/xcomponents/BookReaderPoems.tsx (+ .module.css).
Used by Colaterales. Its distinguishing feature is that it treats the index as a table of contents that dictates poem order.
Parsing pipeline
parseIndex(rawText, locale):- finds the trailing index header block,
- walks its lines: known section names start a section; lines with
....123are poem titles (normalized whitespace, lowercased as keys); - returns
{ knownTitles: Map<key, displayTitle>, sectionTitles: Set, orderedTitles: Map<section, key[]> }.
parseContent:- removes the index from the body, splits into blank-line blocks;
- section-title and image blocks as usual;
- inside a poem section, a line matching a known title starts a new
poem_block; remaining lines become its content; - outside sections, a single all-caps line becomes an empty
poem_block.
- Merge pass: a
poem_blockwith empty content followed by acontentblock is merged into one poem. reorderPoems: for each section, sorts itspoem_blocks by the index order, re-attaching any non-poem blocks to the poem that preceded them (or moving them to the section start if they preceded all poems).- Index page re-appended as a
PageItem.
Render
Content pages use XZigZagLayout (accent line, thickness 0.2) and render each
poem as <h3 class=poemTitle> + XInteractivePhrase. poemWrapper p forces
line-height: 2 !important. Pagination mirrors the shared pattern but uses
useT("BookReader"). The container is a plain column (min-height: 80vh) — no
full-bleed background.
Section names (Colaterales) per locale: TACTO/TRANSFUSIONES/PERSPICACIA/ INTERSECCIÓN/INCERTIDUMBRE (translated for all five). Index headers here use
Inhaltsverzeichnis for German (different from the other engines’ Inhalt) —
a known inconsistency.
5. XBookReader (npm) — Boulevard
[locale]/obras/boulevard/page.tsx renders the npm XBookReader directly:
<XBookReader
rawText={rawText}
coverImage="/images/colecciones/libros/boulevard.webp"
/>
Minimal wrapper (main.min-h-screen.py-16.px-4.md:px-8.max-w-7xl.mx-auto). No
background rotation, no section overrides. Boulevard content files:
src/app/content/boulevard/{locale}.mdx.
6. Choosing / adding an engine
- Plain paginated poems, no art →
XBookReader. - Section pages + index + a single background, generic word interaction →
XCompleteBook. - Rotating full-bleed backgrounds and/or per-word color + decrypt →
XBookColors(+ a wrapper if you want preloading). - Poems whose reading order is defined by an index / titles →
BookReaderPoems.
When adding a book:
- Author the five MDX files.
- Add cover + background frames to
public/images/obras/<book>/. - Create
[locale]/obras/<book>/page.tsxusing the file-resolution pattern fromcontent-pipeline.md. - If backgrounds rotate, add a client wrapper (copy
AsintotaBook) with the correctTOTAL_BG. - Register the book in
ObrasClientPageand (optionally)poemParser. - Translate section-name tables and
Books.*strings into all locales.
7. Known reader pitfalls
- Background frame counts must match
TOTAL_BG/bgConfig.total, else the reader requests a missingbgNN.webp(blank frame). - Section-name tables are per-engine and duplicated across locales; adding a section only in one locale makes it render as plain content in the others.
- Index parsing differences (German
InhaltvsInhaltsverzeichnis) mean the same book content cannot be moved between engines without adjusting the index header. groupIntoPagesalternates 4/5 items based on the currentpages.lengthparity, so inserting/removing a section page shifts pagination for everything after it — expected, not a bug.- Short poems rely on the explicit type-assignment fallback; do not remove it or blurred-only poems become unrevealable.
- Theme is read from
data-themeonly (no system preference); reader colors update viaMutationObserverwhen the navbar toggles the theme.
build-and-deploy
Xscriptor Build & Deployment
Everything needed to build, validate and ship xscriptor.com, plus the static hosting contract. The site is a 100% static export — there is no server runtime, no PHP and no API.
1. Scripts (package.json)
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"postbuild": "next-sitemap"
}
}
-
npm run dev— local dev server onhttp://localhost:3000. -
npm run build— production build.next buildwrites the static site toout/, thenpostbuildrunsnext-sitemap(npm automatically runspostbuildafterbuild). -
npm run start— serves the production build locally (not used in production; the site is static). -
npm run lint—next lint. On Next 16 this can be unreliable; the project’s actual validation is TypeScript:npx tsc --noEmit
Requirements: Node 18+ and npm. No environment variables are required.
2. Next.js configuration
next.config.ts:
const nextConfig: NextConfig = {
output: "export", // static HTML export to out/
trailingSlash: true, // every route emitted as /path/index.html
images: { unoptimized: true }, // no Image Optimization API at runtime
};
export default nextConfig;
Consequences:
- Every route must be statically renderable. Do not add route handlers,
middleware,
revalidate, server actions, or anything that needs a request. images.unoptimizedmeansnext/imageacts like a plain<img>(used inInfoClientPagefor the signature). Remote images must be plain<img>.trailingSlash: trueis why.htaccessrewrites/path/→/path.html.postcss.config.mjsregisters@tailwindcss/postcss; Tailwind v4 has notailwind.config.*— theme tokens are plain CSS variables inglobals.css.
TypeScript (tsconfig.json)
strict: true,target ES2020,moduleResolution: "bundler",jsx: "react-jsx",resolveJsonModule: true(Top-level JSON imports such asascii-frames.jsonand the message files rely on this).- Path alias:
"@/*": ["./src/*"]→ use@/app/...,@/lib/.... exclude: ["node_modules", "web"].
3. Build output layout
next build
→ out/ # deployable static site
index.html # /
es/index.html # /[locale] for each locale
es/blog/index.html
es/blog/<slug...>/index.html
es/obras/<book>/index.html
...
_next/static/... # JS/CSS chunks
images/, fonts/, *.png, x-public.asc
404.html
robots.txt # generated by next-sitemap (postbuild)
sitemap.xml
sitemap-0.xml
.htaccess # copied from public/
next-sitemap (postbuild)
Rules:
- Files in
public/are copied verbatim toout/at the same paths. robots.txt,sitemap.xmlandsitemap-0.xmlare generated; never add source copies inpublic/ornext-sitemapmay skip/override them.- To change
.htaccess, editpublic/.htaccessand rebuild (it is copied). - To change the domain in the sitemap, edit
next-sitemap.config.jssiteUrl.
4. Sitemap & robots — next-sitemap.config.js
module.exports = {
siteUrl: 'https://xscriptor.com',
generateRobotsTxt: true,
sitemapSize: 10000,
exclude: ['/api/*', '/blog/post'], // legacy paths; safe to trim
};
postbuildregeneratesrobots.txt(allows crawlers, points to the sitemap) and the sitemaps.- The
excludeentries are historical (no API or/blog/postroute exists). Removing them is safe but low-value. - Per-page priorities/
changefreqare not customized.
5. Security headers
Two header files are shipped because the site can be hosted on either Apache
(primary, via .htaccess) or a _headers-aware platform.
public/.htaccess (Apache)
- Static rewrite for
trailingSlashoutput:RewriteEngine On RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^(.*)/$ $1.html [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ /index.html [L] - Headers (via
mod_headers):Strict-Transport-Security: max-age=31536000; includeSubDomains; preloadX-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; media-src 'self'; connect-src 'self'; frame-ancestors 'self'; form-action 'self'
- Sensitive file blocking:
<FilesMatch "\.(env|git|json|lock|md|mdx)$"> Require all denied</FilesMatch>. - Cache-Control (
mod_expires): webp/jpg/png/svg and woff2 → 1 year; css/js and mp4 → 1 month. - Compression (
mod_deflate): html, css, plain, xml, js, json, svg.
'unsafe-inline'inscript-src/style-srcis required by the Next.js static export (inline theme script, inline styles). A nonce/hash CSP is not possible without SSR. If you add a third-party script, you must extend the CSP explicitly.
public/_headers (Netlify/Cloudflare Pages style)
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()
Note the intentional differences from .htaccess (DENY vs SAMEORIGIN, no
HSTS/CSP here). Keep both in mind: which one applies depends on the host.
6. Hosting
- Target: static hosting (Apache/Hostinger being the primary environment
given
.htaccess). Any static host works if it honorstrailingSlashroutes (Netlify/Cloudflare/Vercel static, GitHub Pages with rewrites, etc.). - Deploy = upload the contents of
out/(including.htaccess). - No build step on the server; no Node process.
public/x-public.ascis fetched at runtime byPublicKeyCard— ensure it is present in the deployed output (it is copied automatically).public/images/**is large (pre-rendered book backgrounds: 72+31+30 frames). Consider CDN/caching; the.htaccessalready sets 1-year caching for images.
7. Validation checklist before shipping
npx tsc --noEmit— type check (authoritative;next lintmay be flaky).npm run build— must succeed and generateout/withrobots.txtandsitemap.xml.- Spot-check
out/es/index.html, one book route, one article route. - Confirm
.htaccessandx-public.ascexist inout/. - Verify both themes and
<768pxon the changed pages. - Verify
prefers-reduced-motionbehavior if you touched animation. - Never commit secrets; there are none in this repo by design.
Common build failures
| Symptom | Cause / fix |
|---------|-------------|
| useState is not a function during export | A Server Component imported a client component directly from @xscriptor/xcomponents. Import via the "use client" barrel instead. |
| Missing page during export | A route lacks generateStaticParams or reads a file that is not present for some locale. |
| Blank book frame | Background frame count ≠ TOTAL_BG/bgConfig.total. |
| Sitemap missing | postbuild did not run; run npx next-sitemap after next build. |
| Type error on JSON import | resolveJsonModule disabled or import path wrong. |
| Stale sitemap/robots | A robots.txt was added to public/; remove it and rebuild. |
code-structure
Xscriptor Code Structure
Where code goes, what each layer is responsible for, and the rules that keep the site consistent. Canonical placement rules — the current tree is the source of truth; this document explains the intent behind it.
1. Current tree
xscriptor.com/
├── src/
│ ├── app/
│ │ ├── layout.tsx # root shell: metadata, theme script, providers, footer
│ │ ├── page.tsx # "/" (Spanish home)
│ │ ├── globals.css # Tailwind v4 import + tokens + global element rules
│ │ ├── i18n-provider.tsx # I18nProvider, useLocale, useT (+ t.raw)
│ │ ├── not-found.tsx # client 404
│ │ ├── favicon.ico, icon.svg # file-convention icons
│ │ │
│ │ ├── [locale]/
│ │ │ ├── layout.tsx # per-locale provider, hreflang, <html lang>
│ │ │ ├── page.tsx # localized home
│ │ │ ├── UpdateHtmlLang.tsx
│ │ │ ├── blog/
│ │ │ │ ├── page.tsx # listing (server) → BlogListClient
│ │ │ │ └── [...slug]/page.tsx # article (server) → XBlogDecrypt
│ │ │ ├── contacto/
│ │ │ │ ├── page.tsx
│ │ │ │ └── ContactoClientPage.tsx
│ │ │ ├── info/
│ │ │ │ ├── page.tsx
│ │ │ │ └── InfoClientPage.tsx
│ │ │ ├── links/page.tsx
│ │ │ ├── obras/
│ │ │ │ ├── page.tsx
│ │ │ │ ├── ObrasClientPage.tsx
│ │ │ │ ├── boulevard/page.tsx
│ │ │ │ ├── asintota/{page.tsx, AsintotaBook.tsx}
│ │ │ │ ├── cielos-de-alquitran/{page.tsx, CielosDeAlquitranBook.tsx}
│ │ │ │ ├── colaterales/page.tsx
│ │ │ │ ├── primavera-en-el-desierto/page.tsx
│ │ │ │ ├── la-danza-de-las-amapolas/{page.tsx, AmapolasBook.tsx}
│ │ │ │ └── grafo/{page.tsx, GrafoPage.module.css}
│ │ │ └── terminos-y-condiciones/{page.tsx, TermsClientPage.tsx}
│ │ │
│ │ ├── components/
│ │ │ ├── clientcomponenthome.tsx # home hero
│ │ │ ├── ClientComponentHome.module.css
│ │ │ ├── AsciiLoadingAnimation.tsx
│ │ │ ├── ascii-frames.json # precomputed intro frames
│ │ │ ├── LenisProvider.tsx
│ │ │ ├── transitionProvider.tsx # navbar + language dock + route overlay
│ │ │ ├── blog/BlogListClient.tsx
│ │ │ ├── icons/navbar/navbarIcons.tsx
│ │ │ ├── layout/ConditionalSeparator.tsx
│ │ │ ├── layout/footer/XFooterComponent.tsx
│ │ │ ├── publickey/{PublicKeyCard.tsx, PublicKeyCard.module.css}
│ │ │ ├── socialgrid/SocialGrid.tsx
│ │ │ ├── xbackgrounds/ParticlesBackground.tsx
│ │ │ ├── Xtexts/XTextDecrypt/{XTextDecrypt.tsx, index.ts}
│ │ │ └── xcomponents/ # see below
│ │ │
│ │ ├── blog/
│ │ │ ├── BlogListPage.module.css # CSS for BlogListClient
│ │ │ └── [slug]/ArticlePage.module.css # CSS for the article page
│ │ ├── contacto/ContactPage.module.css
│ │ ├── info/InfoPage.module.css
│ │ ├── obras/ObrasPage.module.css
│ │ │
│ │ ├── content/ # authored content (not code-gen)
│ │ │ ├── articulos/{locale}/**/*.md
│ │ │ └── <book>/{locale}.mdx
│ │ │
│ │ └── lib/{articles.ts, i18n-utils.ts}
│ └── lib/poemParser.ts
│
├── messages/{es,en,de,it,fr}.json
├── public/ # static assets + .htaccess + _headers
├── docs/{STRUCTURE.md, xcomponents-fixes.md}
├── next.config.ts, next-sitemap.config.js, postcss.config.mjs
├── eslint.config.mjs, package.json, tsconfig.json, README.md, CONTRIBUTING.md, CONTACT.md
└── LICENSE
src/app/components/xcomponents/ (local reusable UI)
xcomponents/
index.ts # "use client" barrel (the import surface)
xglassnavbar/{XGlassNavbar.tsx, XGlassNavbar.module.css}
xzigzagvideo/{XZigZagLayoutVideo.tsx, .module.css}
xhomecolors/{XHomeColors.tsx, .module.css}
xbookcolors/{XBookColors.tsx, .module.css, README.md}
xcompletebook/{XCompleteBook.tsx, .module.css} # NOTE: currently unreferenced
xminimalfooter/XMinimalFooter.tsx
BookReaderPoems.tsx + BookReaderPoems.module.css
grafo/{GrafoPoetico.tsx, GrafoPoetico.module.css}
xblogdecrypt/{XBlogDecrypt.tsx, README.md, index.ts}
Naming quirk: most CSS modules live in non-route sibling folders
(src/app/blog/, src/app/obras/, src/app/contacto/, src/app/info/) while
the route pages live under [locale]/. Components import them with the absolute
alias, e.g. import styles from "@/app/blog/BlogListPage.module.css". Follow
this pattern rather than moving CSS next to the [locale] page.
2. Layer responsibilities
Route entry (page.tsx) — Server Component
- Export
metadataorgenerateMetadata(SEO + Open Graph locale). - For localized routes,
await paramsto get the locale. - Fetch build-time data (
getSortedArticles,getArticleData,getAllPoems,fs.readFileSyncfor books). - Delegate all interactivity to a client component (
*ClientPage, readers,BlogListClient) — do not put hooks in the page. - Keep the page thin; layout lives in CSS modules or the client component.
layout.tsx
- Root: global metadata, theme
<Script>,I18nProvider(es),LenisProvider,TransitionProvider, separators,main, footer, skip link. [locale]: validate locale, inject the correct messages,UpdateHtmlLang, emit hreflang alternates.- Do not add page-specific content to layouts.
Client page component (*ClientPage.tsx)
- Owns local UI state and
useT-based copy. - Composes reusable components; keeps JSX declarative.
- Often pairs with
ParticlesBackground(contact/info).
Reusable UI
- Site-specific reusable blocks →
src/app/components/<area>/with a colocated*.module.css, exported through the barrel if server components need them. - Cross-project library components →
@xscriptor/xcomponents(update the package + bump version; never fork). - Animation primitives (
XTextDecrypt,XBlogDecrypt) stay in the app when they depend on app-specific content shape.
Data / parsing
src/app/lib/articles.ts— blog markdown pipeline (build-time only).src/app/lib/i18n-utils.ts—getMsgfor server components.src/lib/poemParser.ts— aggregate poems for the graph (build-time only).- Any new
fs-using parser: keep it in alib/module, call it only from server components.
Content
src/app/content/articulos/{locale}/— blog markdown.src/app/content/<book>/{locale}.mdx— book text.- Author content by hand; never generate it at runtime.
Messages
messages/{locale}.json— UI strings, namespaced.- Add keys to all five locales simultaneously.
CSS
- Global tokens, element resets,
.article-content, utilities, keyframes →globals.cssonly. - Route/section layout → the route’s
*.module.css. - Component internals → the component folder’s
*.module.css. - Prefer Tailwind utilities for spacing/layout in simple JSX; use CSS modules for anything stateful or repeated. Do not mix heavy Tailwind noise into a component that already has a module.
3. Import rules
- Use the path aliases:
@/app/...,@/lib/.... - Import reusable components from the barrel:
import { XBookColors } from "@/app/components/xcomponents"; - Never import
@xscriptor/xcomponentsdirectly from a Server Component — go through the"use client"barrel (seearchitecture.md§6). - The barrel must stay
"use client". If you add a component that needs client hooks, add its re-export line toindex.ts.
4. Ideal rules for future changes
- Route-only logic stays near the route; shared logic moves to
lib/. - Reusable UI moves to
xcomponents/and, if browser-only, through the barrel. - Structured content moves to
content/; interface copy moves tomessages/. - Give a component its own
module.cssthe moment it develops a visual language. - Prefer TypeScript for all new files; migrate stray
.jsx/.jswhen touched. - Add new locales to every locale list (or better, centralize them — see §6).
- Use
clamp()for fluid sizing; cap wide-screen padding. - Verify both themes and
<768pxafter any visual change. - Run
npx tsc --noEmitthennpm run buildbefore finalizing.
5. Anti-patterns
- Large inline data arrays inside
page.tsx(put them in aconstbeside the route or inlib/). - Multiple unrelated widgets sharing one CSS module.
- Hardcoded colors instead of
var(--bg|--text|--accent|--border)(the book / graph palettes are the only exceptions). - Hardcoded user-facing text that never reaches
messages/*.json(or, for the documented per-component dictionaries, at least translated in place). - Copying npm components into
xcomponents/instead of using the barrel. - Editing the npm
@xscriptor/xcomponentspackage insidenode_modules. - Importing
@xscriptor/xcomponentsdirectly in a Server Component. - Using
vwfor padding withoutclamp(). - Adding a key to only one locale’s message file.
- Hand-authoring
robots.txt/sitemap.xmlinpublic/(next-sitemap owns them). - Adding server-only APIs (
route.ts, middleware, revalidate) — export is static. - Using
dangerouslySetInnerHTMLon untrusted input (XBlogDecrypt/Grafo inject generated HTML into trusted, in-repo content only).
6. Known structural debt (fix opportunistically)
- Locale list duplication across ~9 files. A single
src/lib/locales.tsexportingLOCALES,Locale,FALLBACK_LOCALE,INDEX_HEADERS,OG_LOCALEwould remove most of it. - Two locale regexes —
XFooterComponentandnot-found.tsxonly handleen|es|de, so Italian/French fall back to Spanish. Align them withtransitionProvider. - Inline nav/reader dictionaries in components vs
messages/. MigrateTransitionProviderlabels and reader section names into messages when convenient. - Unreferenced local
XCompleteBook.tsx— delete or wire it up. - Unused deps (
gsap,react-intersection-observer) — confirm and prune. a:hover { color:#ff4141 }global override fights accent links; scope it.InfoPage15vw padding should becomeclamp()for small screens.
7. Preferred editing strategy for AI agents
- Read the route entry (
page.tsx) and its client component. - Read the relevant CSS module and the data file it depends on.
- If a reusable block is needed, add it under
components/and, if it must be server-importable, to the barrel. - Keep new copy in
messages/*.jsonfor all five locales (plus per-component tables where the codebase already does so). - Respect the design system (
design-system.md): tokens, marker chips, motion budget, reduced motion. - Verify light/dark and mobile.
npx tsc --noEmit→npm run build.- Never commit secrets; never add a runtime server dependency.
component-catalog
Xscriptor Component Catalog
Every component in the project, what it does, its props/API, and where it lives. Use this before writing anything new: most needs are already covered by an existing component.
Legacy note: older docs list XContactForm, XNewsletter and XSocialContact
as actively used. They are re-exported from the barrel (npm) but no page
currently renders them — the contact page uses PublicKeyCard + SocialGrid.
The newsletter backend does not exist. Do not assume they are wired up.
Part A — Barrel imports (src/app/components/xcomponents/)
index.ts ("use client") is the only sanctioned import surface for reusable
components. Never import @xscriptor/xcomponents directly from a Server
Component.
From @xscriptor/xcomponents (npm)
| Component | Purpose |
|-----------|---------|
| XNavbar | Legacy navbar (superseded by local XGlassNavbar) |
| XFooter | Legacy footer primitive |
| XSeparator | Decorative separator; supports orientation, variant (dashed), hasX, color, xColor, thickness, gap, isFaded, xBg, className |
| XZigZagLayout | Alternating left/right content stack with an optional scroll-drawn SVG connector. Props: startSide, gap, offset, textAlign (inherit\|side\|left\|right), showLine, lineColor, lineThickness, className |
| XBookReader | Paginated book reader (used by Boulevard) |
| XBookReaderIllus | Illustrated book reader variant |
| XInteractivePhrase | Renders WordConfig[] with interactive word types + per-word decrypt; prop as selects the tag; used by readers and XZigZagLayoutVideo |
| XDecryptedText | Text-decrypt primitive |
| XCompleteBook | Full book engine: cover, section titles, index, zigzag pages, pagination, optional background image/video, optional renderPoem override, sectionNames, onPageChange, labels |
| XBookFullDecrypt | Poem renderer variant (per-word decrypt) exported by the npm package |
| XContactForm / XNewsletter / XSocialContact | Re-exported but unused in current pages |
Local components
XGlassNavbar — xcomponents/xglassnavbar/XGlassNavbar.tsx
Apple-style floating glass navbar. The center logo doubles as the theme
toggle. Desktop shows linksLeft/linksRight; below 768px it collapses to a
fixed glass hamburger + full-screen overlay menu with a theme row.
Key props: linksLeft, linksRight ({ url, title, external?, showExternalIcon? }),
logo, logoAsThemeToggle, onLogoClick, themeIcons: { toDark, toLight },
defaultTheme, storageKey (default "theme"), linkColor, linkHoverColor,
linkActiveColor, iconColor, iconHoverColor, iconSize,
hamburgerBarWidth, hamburgerBarThickness, cssVars, labels
(labelOpen, labelClose, labelDark, labelLight, navLabel, menuLabel,
linkLabelPrefix), themeToggleAriaLabel, themeToggleTitle, className.
Behavior worth knowing:
- Hydration-safe theme sync with a
themeReadygate (seearchitecture.md§7). isActive(url): exact match, thenstartsWith(url + "/")with guards that a bare"/"or"/xx"locale prefix never matches everything (patched locally — seedocs/xcomponents-fixes.md).- Locks body scroll via
body.menu-openwhile the overlay is open; closes on route change andEscape. - Theme icon reveal on logo hover is pure CSS (width/opacity transition).
- The mobile theme row is hidden when
themeIconsis not provided.
XZigZagLayoutVideo — xcomponents/xzigzagvideo/XZigZagLayoutVideo.tsx
XZigZagLayout with an optional full-bleed background videoSrc/imageSrc and
an overlay. Measures each child's center point with getBoundingClientRect,
builds an SVG polyline, and animates strokeDashoffset with scroll progress
(clamped [0,1], start at viewport mid). Adds fadeInUp on items when the
wrapper enters the viewport (IntersectionObserver, threshold 0.15). Props:
startSide, gap, offset, textAlign, showLine, lineColor,
lineThickness, videoSrc, imageSrc, overlayColor, plus div props.
Renders no background wrapper when neither videoSrc nor imageSrc is given.
XHomeColors — xcomponents/xhomecolors/XHomeColors.tsx
Renders a WordConfig[] phrase (tag selects h1..span) with the marker chip
background, cycling book colors every third chunk. Word types:
underline (toggles blur group 1), button (toggles blur group 2),
blur1/blur2 (blur until their toggle is on), normal. Supports italic,
bold, breakAfter; an empty text renders a line break. Focusable/clickable
word types get role="button" + Enter/Space handling. Text is rendered through
an inline per-char decrypt that animates on first view. Reads the theme via
MutationObserver.
XBookColors — xcomponents/xbookcolors/XBookColors.tsx
Local reader used by Cielos de Alquitrán, Primavera and La danza de las
amapolas (page-level). Parses raw MDX into section_title | image | content
items + a trailing index, groups into pages alternating 4/5 items, renders
each content page through XZigZagLayout + XBookFullDecrypt (private), and
supports backgrounds from bgConfig (basePath, total, digits, format),
backgroundImage or backgroundVideo with overlayColor. Also accepts
coverImage, locale, labels, sectionNames, onPageChange. See
book-readers.md for the parsing algorithm. Its README documents the resolved
bugs: blur applied via inline styles, explicit short-poem type assignment,
sr-only duplication, Roman-numeral sections, sequential decrypt, theme
detection without system fallback, color cycling.
BookReaderPoems — xcomponents/BookReaderPoems.tsx
Index-driven reader used by Colaterales. Reads the trailing “Índice/Index/…”
block to build a title→order map, identifies section titles and poem titles,
reorders poems to match the index, merges trailing title-only blocks with the
following content block, then paginates with the standard 4/5 scheme. Section
names and index headers are per-locale tables. Uses useT("BookReader") for
prev/next/pageOf/index/coverAlt. See book-readers.md.
XMinimalFooter — xcomponents/xminimalfooter/XMinimalFooter.tsx
Props copyright, links: { label, href }[]. Renders a centered, low-opacity
footer line with · separators.
GrafoPoetico — xcomponents/grafo/GrafoPoetico.tsx (+ GrafoPage.module.css in the route)
Canvas force-directed graph of all poems. Nodes come from poemParser
(getAllPoems), colored by bookIndex; edges connect each node to the next two
in the same book. Features: custom physics (repulsion/centering/edge/alpha),
pan (drag background), zoom (wheel/pinch, clamp 0.12–4), node drag, hover
highlight + radial glow, connected-node highlight, live search with match
highlighting and a result count, a legend, a tooltip, and a framer-motion modal
showing the full poem (markdown bold/italic converted to HTML via formatLine).
Per-locale book labels and UI strings are inline tables. Default 8-color
palette, overridable. The page wraps it in a fixed full-screen container.
XBlogDecrypt — xcomponents/xblogdecrypt/XBlogDecrypt.tsx
Renders article HTML with a decrypt animation on every non-LaTeX text node.
Props: contentHtml (required), title, image, author, date, isoDate,
categories, keywords, readingTime, speed (default 50, article page
passes 10), revealColor (default var(--accent, #e3342f)), scrambleColor
(default #b8a8ff), startDelay (default 450 ms). Renders title → meta →
pills → image → body. The body lives in a React.memo child (XBlogBody) so
React never re-applies dangerouslySetInnerHTML and wipes the hand-injected
span.xblog-text wrappers. A single shared IntersectionObserver reveals text
on scroll after startDelay (so it plays after the route-transition overlay
clears). Skips PRE/CODE/.katex. Idempotent and StrictMode-safe. The README
in xblogdecrypt/ is the authoritative explanation.
XTextDecrypt — components/Xtexts/XTextDecrypt/XTextDecrypt.tsx
General-purpose decrypt text (HTMLMotionProps<'span'>). Props: text,
speed, maxIterations, sequential, revealDirection (start|end|center),
useOriginalCharsOnly, characters, className, encryptedClassName,
parentClassName, animateOn (view|hover), delay. Exposes a .sr-only
copy plus an aria-hidden animated copy. Used by PublicKeyCard for the PGP
fingerprint.
Part B — App-level components (src/app/components/, not in the barrel)
ClientComponentHome — clientcomponenthome.tsx (+ ClientComponentHome.module.css)
The home page hero. Shows an AsciiLoadingAnimation overlay for at least
1.2 s, then two XZigZagLayoutVideo blocks. It reads 14 WordConfig[] arrays
from useT("HomePhrases") and renders them as XHomeColors with mixed tags
(h1, h2, p). Right block has startSide="right". Background images:
/images/blog/conocerlamorfologiadeloindeterminado.webp (block 1) and
/images/colecciones/arte/arte006.webp (block 2). Line uses var(--accent),
thickness 0.3.
AsciiLoadingAnimation — AsciiLoadingAnimation.tsx
Full-screen <pre> ASCII intro. Loads precomputed frames from
ascii-frames.json (top-level JSON import), advances one frame every 35 ms,
fades out after the last frame or after a 4 s hard cap, then calls onDone.
Font size is min(100vw / 150, 100vh / 84), monospace, user-select: none.
aria-busy + aria-live="polite".
LenisProvider — LenisProvider.tsx
Mounts Lenis smooth scroll (duration 1.2, exponential easing,
anchors.offset 80, autoRaf). Disabled under prefers-reduced-motion.
Renders null.
transitionProvider — transitionProvider.tsx
The dominant client chrome component:
- Renders
XGlassNavbarwith locale-aware links:- left: Home
/, Obras/obras, Info/info - right: Contacto
/contacto, Blog/blog, externalhttps://xscriptor.io(</>dev link).
- left: Home
- Renders a fixed bottom-center language dock (ES/DE/EN/IT/FR pills,
aria-current,switchLocale()which rewrites the path preserving the rest of the route). - Renders the route-transition overlay: two black rounded panels
(
border-radius: 100px) collapsing from100vhto 0 (top immediately, bottom delayed 0.2 s) plus a fading path label, keyed bypathnameinsideAnimatePresence mode="wait".
Locale/label maps (LOCALE_KEYS, NAV_LABELS, langLabels) are inline,
per-locale. Icons come from icons/navbar/navbarIcons.tsx.
ConditionalSeparator — layout/ConditionalSeparator.tsx
Renders an XSeparator (dashed, accent, hasX, faded, my-8) except on a
hardcoded list of paths: home, obras, blog, grafo, contacto (all locales), plus
any path containing asintota/asíntota/cielos-de-alquitran/
la-danza-de-las-amapolas. Rendered both above and below main.
XFooterComponent (default export SiteXFooter) — layout/footer/XFooterComponent.tsx
Client component: derives locale from usePathname() (note: only en/es/de),
picks footer strings from the message JSONs via getMsg, and renders
XMinimalFooter with the current year and links to terms + contact.
PublicKeyCard — publickey/PublicKeyCard.tsx (+ .module.css)
Neumorphic PGP card. Fetches /x-public.asc and renders it in a <pre>.
Generates a QR of the hardcoded fingerprint
43086B71054295FF252949AD3F03BDE89BE5176F (qrcode, EC level M, 168px), shows
.asc download, a per-char decrypt of the formatted fingerprint via
XTextDecrypt, a copy-to-clipboard button with a copied confirmation state, and the
SocialGrid. Labels come from useT("ContactPage"). Theme via
:root.light/:root.dark neumorphic shadows.
SocialGrid — socialgrid/SocialGrid.tsx
Five hand-drawn SVG icon links (GitHub, Telegram, Instagram, WhatsApp, Email)
in a circular “badge” style with an XCorner mark. Hover via inline styles
using color-mix on var(--foreground).
ParticlesBackground — xbackgrounds/ParticlesBackground.tsx
Wraps XParticles from @xscriptor/xbackgrounds
(particleCount 1200, trailOpacity 0.07, speed 0.8, light 255,255,255,
dark 10,10,10). Mirrors data-theme to dark/light classes on <html>.
Used on Contacto and Info.
BlogListClient — blog/BlogListClient.tsx
Blog index body. Fixed glass filter bar with a search input and category pills
(derived from article categories, single-select toggle). Renders full-viewport
stacked article cards; the card nearest viewport center is articleActive
(others blurred/dimmed). Each card shows image (or accent gradient), title,
excerpt, up to 2 category pills, localized date, and reading time. Uses
useT('BlogPage') and useLocale(); date locale mapping
de-DE/it-IT/fr-FR/en-US/es-ES. Links to /{locale}/blog/{slug}.
ObrasClientPage — [locale]/obras/ObrasClientPage.tsx
Book gallery. Hardcoded BOOKS array (slug/key/img) plus a final graph card.
Same center-detection + blur-on-inactive behavior as the blog list. Uses
useT() for Books.* titles/descriptions and ObrasPage.*, links to
/{locale}/obras/{slug}.
Reader wrappers ([locale]/obras/*/*.tsx)
AsintotaBook,CielosDeAlquitranBook,AmapolasBook: thin client wrappers that own a page→background rotation (TOTAL_BG72 / 30 / 31), preload the next background withnew Image(), and pass labels (and, for Amapolas,sectionNames+renderPoem={<XBookFullDecrypt/>}) into the reader.- Boulevard page inlines the npm
XBookReaderdirectly.
Icons — icons/navbar/navbarIcons.tsx
SunIcon and MoonIcon (stroke, currentColor, size/strokeWidth/title props,
role="img" + aria-hidden/aria-label).
not-found.tsx
Client 404: a dashed book SVG in accent color, big 404, localized message and
“back home” link. Locale is derived from usePathname() with
/^\/(en|es|de|it|fr)/ (unlike XFooterComponent, which only matches
en|es|de).
UpdateHtmlLang
Tiny client effect that sets document.documentElement.lang = locale.
Part C — Component recipes
“Animated word / phrase”
Use XHomeColors (home) or XInteractivePhrase / XBookFullDecrypt
(books). Words are WordConfig[] (see content-pipeline.md).
“Text that decrypts on scroll”
Wrap with XTextDecrypt (animateOn="view") or, for whole HTML articles,
XBlogDecrypt.
“Full-bleed art section with text”
XZigZagLayoutVideo with imageSrc/videoSrc + overlayColor, children as
XHomeColors/marker chips.
“Paginated book”
Pick the engine from book-readers.md; never hand-roll pagination.
“New reusable UI block”
- If it belongs to the shared library, add it to
@xscriptor/xcomponentsand bump the version — do not fork it locally. - If it is site-specific, add it under
src/app/components/...with a CSS module, and — if it must be importable from Server Components — export it through the"use client"barrel. - Honor reduced motion and both themes.
content-pipeline
Xscriptor Content Pipeline
How content is authored, stored, parsed and localized:
- Blog articles — Markdown → HTML at build time (
articles.ts). - Books — MDX per locale → raw text → one of four reader engines.
- Poem index (graph) — all books aggregated from disk (
poemParser.ts). - UI strings / home phrases —
messages/{locale}.json+I18nProvider.
1. Directory layout
src/app/content/
articulos/
es/**/*.md # Spanish articles (source of truth)
en/**/*.md # English translations (same slugs)
de/**/*.md it/**/*.md fr/**/*.md
<book>/
es.mdx en.mdx de.mdx it.mdx fr.mdx
cielos-de-alquitran/
cielos-de-alquitran.mdx # ES file uses this special name
en.mdx de.mdx it.mdx fr.mdx
messages/
es.json en.json de.json it.json fr.json
Books present: boulevard, asintota, primavera-en-el-desierto,
la-danza-de-las-amapolas, colaterales, cielos-de-alquitran.
Blog articles are grouped in subfolders per topic (e.g. chat-control/,
kyber/, obscura/) plus flat .md files. The research/ subfolder inside a
topic is excluded from the blog listing but still reachable (see §2).
Public imagery (covers + reader backgrounds):
public/images/blog/*.webp|jpg
public/images/obras/<book>/<cover>
public/images/obras/<book>/<bookbook>/bgNN.webp # reader backgrounds
public/images/obras/grafo/background.webp
public/images/colecciones/{arte,libros}/*.webp
Background counts: asintota/asintotabook = 72, amapolasbook = 31,
cielosdealquitranbook = 30.
2. Blog articles — src/app/lib/articles.ts
Build-time, fs-based, locale-aware. These functions run only in Server
Components / build (params/metadata). JSON/disk paths are relative to
process.cwd().
Constants
const articlesBaseDir = path.join(process.cwd(), "src/app/content/articulos");
const FALLBACK_LOCALE = "es";
const SUPPORTED_LOCALES = ["es", "en", "de", "it", "fr"];
Frontmatter contract
---
title: "Article Title"
date: "2024-01-01" # ISO; sort key (desc)
description: "Short summary"
categories: ["chat-control"] # primary taxonomy; drives listing + filters
tags: ["..."] # fallback for keywords
keywords: ["..."] # preferred pills on the article page
image: "/images/blog/x.webp"
author: "Xscriptor"
excerpt: "Optional custom excerpt"
---
Functions
| Function | Behavior |
|----------|----------|
| getSortedArticles(locale = es) | Lists top-level .md files and one level of non-research subdirectories; parses each; sorts by date desc. Falls back to es if the locale dir is missing. |
| getAllArticleSlugs(locale = es) | Union of locale slugs + es fallback slugs (set), returned as { params: { slug } }. Nested slugs keep folder/file. |
| getArticleData(slug, locale = es) | Reads {locale}/slug.md, falling back to es. Runs the full markdown→HTML pipeline. Returns null if absent. |
| getArticlesByCategory(category, locale = es) | Case-insensitive category filter over getSortedArticles. |
ArticleMetadata
{ slug, title, description?, date, categories: string[],
tags?, keywords?, excerpt?, readingTime: number, image?,
author?, contentHtml? }
readingTime = ceil(words / 200).excerptfalls back todescription, then to the first 150 chars of body with headings stripped, suffixed"...".toStringArraynormalizes scalar/array frontmatter.datedefaults to today if missing.
Markdown → HTML pipeline (in getArticleData)
matter(content) // gray-matter frontmatter
→ stripDuplicateLeadMeta(body, title) // remove duplicate H1 + "Publicado por" block
→ unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkMath)
.use(remarkRehype)
.use(rehypeKatex)
.use(rehypeHighlight)
.use(rehypeStringify)
Post-processing on the resulting HTML:
- Soft-break →
<br>. Converts single\ninsidep,h1-6,blockquote,li,div(skips anything containingkatexorpre/code), becauseremark-rehypedoes not emit<br>for soft breaks. - Table wrapping. Wraps every
<table…</table>in<div class="table-wrapper">for horizontal scroll.
stripDuplicateLeadMeta removes a leading # H1 (duplicate of the page title)
and an immediately following **Publicado por …** paragraph. This is why
article bodies never show the title/author twice.
Consumer
- Listing:
[locale]/blog/page.tsx→getSortedArticles(locale)→BlogListClient. - Article:
[locale]/blog/[...slug]/page.tsx:generateStaticParamsbuilds{ locale, slug: slug.split('/') }for all five locales ×getAllArticleSlugs()(ES slugs).generateMetadatacallsgetArticleData.- Page renders
XBlogDecryptwith de-duplicated categories/keywords, localized date (de-DE/it-IT/fr-FR/en-US/es-ES), reading-time label fromBlogPage.readingTime, andspeed={10}. [...slug]is a catch-all, so nested/folder slugs work unchanged.
next-sitemap.config.jsexcludes/blog/post(a legacy route that no longer exists). Removing the exclude is safe.
3. Book content (MDX as plain text)
Book pages read the MDX as a raw string (fs.readFileSync) and hand it to a
client reader. No MDX runtime, no frontmatter parsing for books. The readers
parse the string themselves with blank-line-separated blocks.
File resolution pattern (pages)
const filePath = path.join(process.cwd(), `src/app/content/${book}/${locale}.mdx`);
const fallbackPath = path.join(process.cwd(), "src/app/content/${book}/es.mdx");
const rawText = fs.readFileSync(fs.existsSync(filePath) ? filePath : fallbackPath, "utf-8");
Exception: cielos-de-alquitran ES file is cielos-de-alquitran.mdx, handled
explicitly by both poemParser.ts (LOCALE_FILES) and the page.
Structural syntax understood by the readers
- Blank line(s) separate blocks. Readers split on
\n{2,}(XBookColors / XCompleteBook) or\n{3,}/\n{2,}(poemParser / BookReaderPoems). - Section title: a block whose first line matches a known section name (all-caps tables per book/locale). Becomes a dedicated page.
- Image: a whole block of the form
. - Poem / content block: anything else.
- Index: a trailing block starting with the locale’s index header
(
Índice/Index/Inhalt/Indice/Index) followed by entries. Entries with....123or- 123are poem entries; other lines are section names. The index is stripped from the body and rendered as its own page;BookReaderPoemsalso uses it to reorder poems.
Section-name tables (per book/locale)
| Engine | Sections (ES) |
|--------|---------------|
| XBookColors default | LUCIÉRNAGAS, UNIVERSO, CONSTELACIONES, INSTANTES, AUTOPSIA, I, II, III, IV, V |
| XCompleteBook default | CURVA, PLANO CARTESIANO, RECTA, I, II, III, IV, V |
| BookReaderPoems (Colaterales) | TACTO, TRANSFUSIONES, PERSPICACIA, INTERSECCIÓN, INCERTIDUMBRE |
| AmapolasBook override | LUCIÉRNAGAS, UNIVERSO, CONSTELACIONES, INSTANTES, AUTOPSIA |
All books also carry translated section names in de/it/fr/en.
These tables live inside the components, not in
messages/. When a new book is added with custom sections, passsectionNames(XCompleteBook / XBookColors) or extendSECTION_NAMES(BookReaderPoems).
4. Poem index for the graph — src/lib/poemParser.ts
getAllPoems(locale) builds the node set for GrafoPoetico.
- Iterates
BOOKS = [boulevard, asintota, primavera-en-el-desierto, la-danza-de-las-amapolas, colaterales, cielos-de-alquitran];bookIndex(0–5) drives graph node color. Blog blocks getbook: "blog",bookIndex: 6. - For each book, resolves the locale file (special-casing
cielos), falling back toes.mdx. isVerseBlock: a block is verse if it has ≥2 lines and average line length < 60 chars.PROSE_BOOKS = new Set(["colaterales"])allows prose blocks (capped at 600 chars viaPROSE_CONTENT_MAX); other books skip non-verse blocks.- Titles: first short line if plausible, else first ~5 words +
…. - Blog contributions: reads the locale’s flat
.mdfiles (not nested), strips images/headings, splits on\n{2,}blocks >30 chars, and titles them"{articleTitle} — {firstLine}"or"{articleTitle} ({n})".
PoemData = { id, title, content, book, bookIndex }.
5. UI strings and HomePhrases
messages/{locale}.json is the interface string store (see
architecture.md §5 for namespaces). Two consumption patterns:
- Client components:
const t = useT("BlogPage"); t("readingTime", { minutes }). - Server components:
getMsg(msgs, "BlogPage.metadataTitle")(no params).
WordConfig (used by HomePhrases and poem rendering)
type WordConfig = {
text: string; // empty string = hard line break
type?: "underline" | "button" | "blur1" | "blur2" | "normal";
bold?: boolean;
italic?: boolean;
breakAfter?: boolean;
};
Consumed as t.raw<WordConfig[]>("phraseN"). HomePhrases holds phrase1 …
phrase14; ClientComponentHome distributes them across two zigzag blocks.
Semantics of type:
underline— clickable; toggles theblur1group.button— clickable; toggles theblur2group.blur1/blur2— start blurred, unblur when their group is toggled.normal— plain text.breakAfter/ emptytext— force a line break.
When authoring HomePhrases, always include at least one
underlineand onebuttonin a phrase that usesblur1/blur2, otherwise blurred words can never be revealed. The generated poem pipelines apply a similar safety fallback (seexbookcolors/README.md).
6. Adding content — checklists
Add a blog article
- Create
src/app/content/articulos/es/<slug>.mdwith the frontmatter contract. Nested slugs → put it in a topic folder. - Add the translation(s) with the same slug path under
en/de/it/fr/. - Ensure a cover image exists at the referenced
imagepath underpublic/. - Optionally add categories/
keywords; the listing derives its filters fromcategories. npm run build(validatesgenerateStaticParamsand the pipeline).
Add or update a book
- Drop
src/app/content/<book>/<locale>.mdx(all five locales;esrequired). - Add cover + background frames under
public/images/obras/<book>/. - Pick an engine (see
book-readers.md), add the route page under[locale]/obras/<book>/, and — for rotating backgrounds — a thin wrapper. - Register the book in
ObrasClientPage'sBOOKSarray (title/description keysBooks.<key>/Books.<key>Descin all message files). - If it has custom sections, add the per-locale section-name tables.
- If the graph should include it, append to
BOOKSinpoemParser.tsand add itsBOOK_LABELStranslations inGrafoPoetico.
Add a UI string
Add the key to all five messages/*.json files under the right namespace,
then consume via useT(namespace) (client) or getMsg (server).
design-system
Xscriptor Design System
Canonical reference for the visual language of xscriptor.com. Everything here is
extracted from the live source: src/app/globals.css (tokens + global rules),
the CSS-module files, and the inline styles inside components.
1. Design intent
Xscriptor is a literary portfolio: an author/poet/artist presenting books, articles and a contact surface. The visual language is deliberately:
- Typography-first. EB Garamond carries the entire experience. There is no display font, no icon font, no decorative typeface. Words are the interface.
- Marker / highlighter aesthetic. Runs of text are placed on soft
background "chips" (
color-mix(in srgb, var(--bg) 88%, transparent)) so that text stays legible over photographs and full-bleed art. - Restrained dual palette. One accent per theme: deep violet in light mode, warm gold in dark mode. Everything else is background, text and border.
- Atmospheric imagery. Full-viewport photographs and pre-rendered background frames sit behind content with a dark overlay.
- Literary / cryptographic tension. The site mixes emotional, poetic copy with PGP keys, fingerprints and decryption-style animations (text descrambles character by character). This is a signature motif, not a gimmick.
- Motion as reveal. Decryption, staggered fades and blur-to-focus are used to reveal content. Nothing bounces or plays for its own sake.
- Both themes are first-class. Light mode is the marketing default; dark mode is the reading default for book pages and is fully supported everywhere.
When adding UI, ask: does this read like a printed literary edition that has learned to decrypt itself? If not, it is off-brand.
2. Color tokens
All colors are CSS custom properties declared in src/app/globals.css. Use the
variables, never raw hex values (the only sanctioned raw hexes are the book
word-color palette in §5).
Light theme (default, :root)
| Token | Value | Use |
|-------|-------|-----|
| --bg | #ffffff | Page background |
| --text | #000000 | Primary text |
| --accent | #5b2e8d | Links, active nav, borders, pills, decrypt reveal |
| --accent-text | #ffffff | Text on accent-filled surfaces |
| --border | rgba(0, 0, 0, 0.1) | Hairlines, card borders, dividers |
| --foreground | #171717 | Near-black used by neumorphic card + social icons |
| --text-muted | #6b7280 | Secondary/meta text |
| --primary | #4328a8 | Deep violet (select controls, PGP card) |
| --primary-hover | #7c3aed | Hover state for --primary |
| --success | #10b981 | Status dot (PGP key loaded) |
Dark theme (:root[data-theme="dark"], :root.dark)
| Token | Value | Use |
|-------|-------|-----|
| --bg | #0a0a0a | Page background |
| --text | #ffffff | Primary text |
| --accent | #ffe884 | Warm gold: links, active nav, borders |
| --accent-text | #000000 | Text on gold surfaces |
| --border | rgba(255, 255, 255, 0.1) | Hairlines |
| --foreground | #ededed | Off-white |
| --text-muted | #9ca3af | Secondary text |
| --primary | #fbbf24 | Amber |
| --primary-hover | #f59e0b | Amber hover |
| --success | #34d399 | Status dot |
Both selectors are required:
[data-theme="dark"](set by the navbar toggle) and.dark(set on<html>byParticlesBackgroundfor components that key off a class). PublicKeyCard CSS relies on:root.light/:root.dark.
Semantic color rules
- Links default to
var(--accent). Globala:hoverforces#ff4141(a hard red) — this is a legacy rule; prefer component-scoped hover (opacity or accent) for new work. - Syntax highlighting inside
.article-contenthas two full palettes: a light “One Light”-style set and a dark set scoped under[data-theme="dark"]. - KaTeX gets extra left/right breathing room via
.article-content .katex.
3. Typography
Font family
@font-face { font-family: "EB Garamond"; src: url("/fonts/EBGaramond-Regular.woff2") ...; font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: "EB Garamond"; src: url("/fonts/EBGaramond-Italic.woff2") ...; font-weight: 400; font-style: italic; font-display: swap; }
html, body { font-family: "EB Garamond", serif; }- Only woff2 files exist in
public/fonts/(Regular + Italic). The@font-facealso lists.ttfsources that are not present — harmless (the woff2 loads first), but do not rely on the ttf. - Monospace appears only for technical metadata: PGP fingerprint / key,
.ascdownload link, code blocks ('Fira Code', 'Monaco', monospace), and theui-monospaceused by the ASCII intro. It is never used for body copy.
Global element scale (in globals.css)
These apply site-wide and are centered by default:
| Element | Weight | Size | Margins |
|---------|--------|------|---------|
| h1 | 400 | clamp(16px, 12vw, 32px) | 1vw top/bottom |
| h2 | 300 | clamp(24px, 8vw, 26px) | 1vw |
| h3 | 600 | clamp(20px, 9vw, 26px) | 1vw |
| p | 400 | clamp(18px, 2.5vw, 20px) | 2vw + side padding clamp(1rem, 8vw, 4rem) |
Notes:
- The
h1clamp is unusual:12vwmeans it is small on phones and caps at 32px. Do not “fix” it globally — page/components override it where a hero is needed (ObrasPagetitle, section titles use their own clamps). - Paragraphs carry large horizontal padding so prose never spans full-bleed.
Article typography override
.article-content (used by XBlogDecrypt) cancels the centered prose:
p→ left aligned, no side padding,clamp(16px, 2vw, 18px),line-height 1.8.h1–h4→ left aligned;h2gets a bottom border,h3smaller.- Lists, blockquotes (accent left border), inline/fenced code, images, tables,
hr, links (underlined) all get dedicated rules. - Tables are wrapped in
.table-wrapper(added byarticles.ts) for horizontal scroll on mobile; the table getsmin-width: 600px.
Body-text line-height conventions
- Global paragraphs inherit the browser default; literary body copy in readers
uses explicit
line-heightbetween1.7and2(e.g.BookReaderPoemssetsline-height: 2 !importanton poem paragraphs). - Display/marker chips use
line-height: 1.8so multi-line highlighted runs do not collide.
4. Surfaces, borders and effects
Glassmorphism (.glass, navbar, filter bars, modals)
background: color-mix(in srgb, var(--bg) 70%, transparent); /* light */
backdrop-filter: blur(18px);
border: 1px solid var(--border);
/* dark variant */
background: color-mix(in srgb, var(--bg) 15%, transparent);
Used by: XGlassNavbar (blur 20px, radius 9999px, layered shadows),
blog filter bar (blur 18px, radius pill), graph tooltip/legend/modal
(blur 12–30px), GrafoPoetico.
Marker / highlighter chip
The single most repeated visual atom:
background: color-mix(in srgb, var(--bg) 88%, transparent);
padding: 0.1em 0.35em;
border-radius: 0.2em;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
Used for: home/book words (XHomeColors, XBookColors), section titles,
index entries, poem titles, book titles/descriptions, page indicators. It keeps
text readable over any background. Blog-list cards use a darker variant
(color-mix(in srgb, #000 55%, transparent) with white text) because they sit
over imagery.
Neumorphism (PublicKeyCard only)
The PGP card is the one place with a soft-UI treatment. It uses a local
--neumorph-bg token (#e9e9e9 light / #16181d dark) plus hand-written
dual-shadow recipes for raised and inset surfaces (card, QR frame, fingerprint
chip, buttons, key block). Theme is selected by :root.light / :root.dark.
Shadows
- Cover images / inline images:
0 10px 25px rgba(0,0,0,0.2), radius 12px. - Navbar: two stacked shadows (
0 4px 6px -1px+0 8px 32px), stronger in dark. - Modals:
0 8px 48px rgba(0,0,0,0.4). - Book cards (Obras/Blog): overlay
rgba(0,0,0,0.35)–0.4over the photo.
Radii
- Pills / nav / language dock:
9999px. - Cards / cover / modal:
12px–16px(PublicKeyCard up to2rem). - Marker chips:
0.2em; small controls4px–8px.
5. Book word-color palette
The interactive poem readers cycle a fixed palette across word chunks. This is
the only place raw hex values are allowed (they are applied inline as
color and --c-word).
| Key | Dark theme | Light theme |
|-----|-----------|-------------|
| c1 | #fc618d (pink) | #8f002a (wine) |
| c2 | #7eddc9 (teal) | #007052 (green) |
| c3 | #8eeca3 (green) | #265731 (deep green) |
| c4 | #fce566 (yellow) | #7a6e14 (olive) |
| c5 | #b8a8ff (lavender) | #5a4e9e (violet) |
c0 is the surface color (#0a0a0a dark / #ffffff light) and is used as the
foreground when a button word is hovered (accent fill).
Assignment rule (WORD_COLORS, COLOR_INTERVAL = 2): every third chunk gets a
color key, the two in between are null and inherit var(--text). Dark/light
maps are selected by a MutationObserver on document.documentElement's
data-theme attribute (useDetectTheme). There is no prefers-color-scheme
fallback: data-theme !== "dark" means light.
GrafoPoetico uses its own 8-color graph palette:
#ff6b6b, #ffd93d, #6bcb77, #4d96ff, #ff6bff, #ff9f43, #22d3ee, #54a0ff,
overridable via color1…color8 props.
6. Motion & animation language
| Effect | Where | Spec |
|--------|-------|------|
| Route transition | transitionProvider.tsx | Two black border-radius:100px panels collapse 100vh → 0 (top delay 0, bottom delay 0.2s, total ~0.4s) + a fading path label |
| Page/reader fade | XCompleteBook, XBookColors, BookReaderPoems | fadeSlideIn: translateY(20px)→0, opacity 0→1, 0.5s–0.8s ease-out |
| Zigzag stagger | XZigZagLayoutVideo | fadeInUp 0.5s per item, animationDelay = index * 0.12s, triggered by IntersectionObserver |
| Scroll line draw | XZigZagLayout / XZigZagLayoutVideo | SVG path strokeDashoffset tied to scroll progress |
| Text decrypt | XTextDecrypt, XBlogDecrypt, X*Colors | Character-by-character reveal, scrambled tail in scrambleColor; default speed 50 ms (blog body uses 10 ms) |
| Blur reveal | XBookColors/XHomeColors blur words | filter: blur(10px) opacity .3 → blur(0) over 0.7s cubic-bezier(0.4,0,0.2,1) |
| Smooth scroll | LenisProvider | duration: 1.2, exponential ease, anchor offset 80, autoRaf |
| Graph physics | GrafoPoetico | Custom canvas spring: repulsion 1200α, centering .002α, edge .003α, alpha decay ×0.992, live requestAnimationFrame |
| Card hover (info) | InfoPage.module.css | translateY(-5px) + stronger shadow |
| Button/pill hover | everywhere | color swap, background fill, opacity |
Reduced motion
globals.css globally neutralizes animation/transition durations under
@media (prefers-reduced-motion: reduce). Components also guard individually:
LenisProviderdisables smooth scroll entirely.XZigZagLayoutVideohides the background video and disablesitemReveal.XCompleteBook/XBookColorsdisablebookContentanimation.XGlassNavbardisables nav/hamburger/overlay transitions.- Blog/Obras blur-on-inactive is disabled and content shown at full opacity.
New animated components must honor
prefers-reduced-motion, either via the global rule or a component-level guard.
7. Responsive breakpoints
The site uses Tailwind's default breakpoints where utilities are used, and
hand-written media queries elsewhere. Observed working set: 640px, 768px,
1024px, 1200px (max content width).
Key behavior:
XGlassNavbar: desktop link sections (12rem→14rematlg); below768px, link sections are hidden and a fixed glass hamburger + full-screen overlay menu appear.- Full-bleed sections use the “breakout” trick:
width: 100vw; margin-left: calc(-50vw + 50%);(Obras/blog cards, zigzag/video wrappers). Never use rawvwpadding withoutclamp(). - Book readers stack a
readerContainer(min-height: 100vh,justify-content: space-between) so pagination pins to the bottom. InfoPagebio/timeline usepadding: 0 15vwon desktop (a candidate for aclamp()cleanup — see anti-patterns).
8. Accessibility rules already in the codebase
Preserve these patterns in new work:
- Skip link:
.skip-to-contentinglobals.css+<a href="#main-content">in the root layout; it slides in on:focus. .sr-onlyutility (defined globally, also expected by npm components) backs every decryption animation: visible text isaria-hidden="true", and a hidden.sr-onlyspan carries the real text for screen readers.- Focus-visible outlines (
2px solid var(--accent)) on navbar logo, hamburger and mobile theme button. - Navbar exposes
aria-label,aria-expanded,aria-controls; the language dock usesaria-current="true"for the active locale. <html lang>is corrected per locale byUpdateHtmlLang.html { scroll-padding-top: 5rem }keeps anchored content clear of the navbar.suppressHydrationWarningon<html>is intentional (theme attribute set before hydration).
9. Design-system checklist for new UI
- Use
var(--bg),var(--text),var(--accent),var(--border)— never raw hex except the documented book/graph palettes. - Set text on a marker chip when it floats over imagery.
- Prefer
clamp()for fluid type and spacing; cap wide-screen padding. - Verify both themes (toggle is the navbar logo).
- Verify
<768px(navbar collapses) and reduced-motion. - Keep animation subtle and purposeful; reveal, don't decorate.
- Reuse the existing components (see
component-catalog.md) before writing new ones.