@jjlmoya/utils-science 1.53.0 → 1.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/package.json +18 -5
  2. package/scripts/postbuild.mjs +17 -0
  3. package/scripts/postinstall.mjs +2 -4
  4. package/src/components/ProductionBreadcrumb.astro +134 -0
  5. package/src/components/ProductionStructuredData.astro +46 -0
  6. package/src/components/ProductionWidget.astro +182 -0
  7. package/src/i18n/header-ui.ts +15 -0
  8. package/src/i18n/language-ui.ts +9 -0
  9. package/src/i18n/languages.ts +24 -0
  10. package/src/identity/brands.ts +5 -0
  11. package/src/layouts/ProductionCategoryPage.astro +184 -0
  12. package/src/layouts/ProductionPage.astro +218 -0
  13. package/src/layouts/ProductionUtilityPage.astro +283 -0
  14. package/src/mfe/assets.ts +39 -0
  15. package/src/mfe/category-ui.ts +34 -0
  16. package/src/mfe/manifest.ts +45 -0
  17. package/src/mfe/routes.ts +50 -0
  18. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  19. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  20. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  21. package/src/pages/index.astro +1 -2
  22. package/src/pages/mfe-sitemaps/[locale]/[vertical]/sitemap.xml.ts +75 -0
  23. package/src/pages/utilidades/[slug]/manifest.json.ts +28 -0
  24. package/src/pages/utilidades/[slug].astro +90 -0
  25. package/src/pages/utilidades/categorias/[category].astro +38 -0
  26. package/src/tests/diacritics_density.test.ts +1 -1
  27. package/src/tests/inverted_punctuation.test.ts +1 -1
  28. package/src/tests/mfe_cache_contract.test.ts +14 -0
  29. package/src/tests/mfe_manifest.test.ts +28 -0
  30. package/src/tests/registry_contract.test.ts +67 -0
  31. package/src/tests/script_density.test.ts +1 -1
  32. package/src/tests/seo_translation_completeness.test.ts +3 -4
  33. package/src/tests/translation_copy.test.ts +2 -2
  34. package/src/types.ts +5 -7
  35. package/src/worker.ts +27 -0
  36. package/src/pages/[locale]/[slug].astro +0 -162
  37. package/src/pages/[locale].astro +0 -251
@@ -0,0 +1,184 @@
1
+ ---
2
+ import ProductionPage from "./ProductionPage.astro";
3
+ import { Icon } from "astro-icon/components";
4
+ import { SEORenderer } from "@jjlmoya/utils-shared";
5
+ import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
6
+ import type { KnownLocale, CategoryLocaleContent } from "../types";
7
+ import { getCategoryPath } from "../mfe/routes";
8
+ import { getCategoryUi } from "../mfe/category-ui";
9
+
10
+ interface CategoryTool { id: string; icon: string; title: string; description: string; href: string; }
11
+ interface Props { locale: KnownLocale; category: CategoryLocaleContent; tools: CategoryTool[]; alternates: { language: KnownLocale; url: string }[]; image?: string | undefined; }
12
+ const { locale, category, tools, alternates, image } = Astro.props;
13
+ const publicPath = getCategoryPath(locale, category.slug);
14
+ const ui = getCategoryUi(locale);
15
+ ---
16
+
17
+ <ProductionPage title={category.title} description={category.description} {locale} {publicPath} {alternates} {image}>
18
+ <div class="category-production">
19
+ <div class="category-production-inner">
20
+ <ProductionBreadcrumb locale={locale} currentTitle={category.title} />
21
+ <header class="category-header-production">
22
+ <h1>{category.title}</h1><p>{category.description}</p>
23
+ </header>
24
+ <div class="category-tools-grid">
25
+ {tools.map((tool, index) => (
26
+ <a href={tool.href} class="category-tool-card">
27
+ <span class="category-tool-index">{String(index + 1).padStart(2, "0")}</span>
28
+ <div class="category-tool-icon"><Icon name={tool.icon} /></div>
29
+ <h2>{tool.title}</h2><p>{tool.description}</p>
30
+ <span class="category-tool-cta">{ui.useTool} <span aria-hidden="true">→</span></span>
31
+ </a>
32
+ ))}
33
+ </div>
34
+ <section class="category-seo-production"><SEORenderer content={{ locale, sections: category.seo }} /></section>
35
+ </div>
36
+ </div>
37
+ </ProductionPage>
38
+
39
+ <style>
40
+ .category-production {
41
+ min-height: 100vh;
42
+ overflow-x: clip;
43
+ background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
44
+ }
45
+
46
+ .category-production-inner {
47
+ max-width: 1200px;
48
+ margin: 0 auto;
49
+ padding: var(--space-xl) var(--space-md) var(--space-4xl);
50
+ }
51
+
52
+ .category-header-production {
53
+ margin-bottom: var(--space-xl);
54
+ text-align: center;
55
+ }
56
+
57
+ .category-header-production h1 {
58
+ margin: 0;
59
+ color: var(--text-main);
60
+ font-size: clamp(3.5rem, 9vw, 7rem);
61
+ font-weight: 900;
62
+ letter-spacing: -0.06em;
63
+ line-height: 0.95;
64
+ text-transform: lowercase;
65
+ }
66
+
67
+ .category-header-production p {
68
+ max-width: 48rem;
69
+ margin: 1rem auto 0;
70
+ color: var(--text-muted);
71
+ font-size: 1.25rem;
72
+ line-height: 1.6;
73
+ }
74
+
75
+ .category-tools-grid {
76
+ display: grid;
77
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
78
+ gap: var(--space-lg);
79
+ margin-bottom: var(--space-4xl);
80
+ }
81
+
82
+ .category-tool-card {
83
+ position: relative;
84
+ display: flex;
85
+ overflow: hidden;
86
+ min-height: 230px;
87
+ flex-direction: column;
88
+ padding: var(--space-lg);
89
+ border: 1px solid color-mix(in srgb, var(--science-accent) 18%, var(--border-base));
90
+ border-radius: 1.25rem;
91
+ background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--science-accent) 4%, var(--bg-surface)));
92
+ color: inherit;
93
+ text-decoration: none;
94
+ box-shadow: 0 1rem 2rem color-mix(in srgb, var(--shadow-base, #000) 35%, transparent);
95
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
96
+ }
97
+
98
+ .category-tool-card:hover,
99
+ .category-tool-card:focus-visible {
100
+ border-color: var(--science-accent);
101
+ box-shadow: 0 1.25rem 2.5rem color-mix(in srgb, var(--science-accent) 18%, transparent);
102
+ transform: translateY(-4px);
103
+ }
104
+
105
+ .category-tool-card:focus-visible {
106
+ outline: 3px solid color-mix(in srgb, var(--science-accent) 55%, transparent);
107
+ outline-offset: 4px;
108
+ }
109
+
110
+ .category-tool-card::after {
111
+ position: absolute;
112
+ right: -2rem;
113
+ bottom: -3rem;
114
+ width: 9rem;
115
+ height: 9rem;
116
+ border-radius: 50%;
117
+ background: color-mix(in srgb, var(--science-accent) 10%, transparent);
118
+ content: "";
119
+ pointer-events: none;
120
+ }
121
+
122
+ .category-tool-icon {
123
+ position: relative;
124
+ z-index: 1;
125
+ display: grid;
126
+ width: 3.25rem;
127
+ height: 3.25rem;
128
+ place-items: center;
129
+ margin-bottom: var(--space-md);
130
+ border: 1px solid color-mix(in srgb, var(--science-accent) 28%, transparent);
131
+ border-radius: 1rem;
132
+ background: color-mix(in srgb, var(--science-accent) 12%, var(--bg-surface));
133
+ color: var(--science-accent);
134
+ font-size: 1.6rem;
135
+ }
136
+
137
+ .category-tool-index {
138
+ position: absolute;
139
+ top: var(--space-md);
140
+ right: var(--space-lg);
141
+ color: var(--text-dimmed, var(--text-muted));
142
+ font-family: ui-monospace, SFMono-Regular, monospace;
143
+ font-size: 0.72rem;
144
+ letter-spacing: 0.12em;
145
+ }
146
+
147
+ .category-tool-card h2,
148
+ .category-tool-card p,
149
+ .category-tool-cta {
150
+ position: relative;
151
+ z-index: 1;
152
+ }
153
+
154
+ .category-tool-card h2 {
155
+ margin: 0 0 0.5rem;
156
+ color: var(--text-main);
157
+ font-size: 1.25rem;
158
+ }
159
+
160
+ .category-tool-card p {
161
+ margin: 0;
162
+ color: var(--text-muted);
163
+ line-height: 1.55;
164
+ }
165
+
166
+ .category-tool-cta {
167
+ margin-top: auto;
168
+ padding-top: var(--space-md);
169
+ color: var(--science-accent);
170
+ font-weight: 700;
171
+ }
172
+
173
+ .category-seo-production {
174
+ max-width: 56rem;
175
+ margin: 0 auto;
176
+ font-size: 1.125rem;
177
+ line-height: 1.75;
178
+ }
179
+
180
+ @media (width <= 768px) {
181
+ .category-production-inner { padding: var(--space-lg) var(--space-sm) var(--space-3xl); }
182
+ .category-tools-grid { grid-template-columns: 1fr; }
183
+ }
184
+ </style>
@@ -0,0 +1,218 @@
1
+ ---
2
+ import Footer from "@jjlmoya/identity/footer";
3
+ import Header from "@jjlmoya/identity/header";
4
+ import LanguageSheet from "@jjlmoya/identity/language-sheet";
5
+ import LanguageTrigger from "@jjlmoya/identity/language-trigger";
6
+ import ThemeBoot from "@jjlmoya/identity/theme-boot";
7
+ import { getSiteNavigation } from "@jjlmoya/identity/navigation";
8
+ import "@jjlmoya/identity/styles.css";
9
+ import "@jjlmoya/utils-shared/theme.css";
10
+ import "@jjlmoya/utils-shared/styles/jjlmoya-shared.css";
11
+ import { HEADER_UI } from "../i18n/header-ui";
12
+ import { LANGUAGE_LOCALES, LANGUAGE_NAMES, type Language } from "../i18n/languages";
13
+ import { LANGUAGE_UI } from "../i18n/language-ui";
14
+ import { getBrand } from "../identity/brands";
15
+ import { getBrandAssetPath } from "../mfe/assets";
16
+
17
+ interface AlternateLink { language: Language; url: string; }
18
+ interface Props {
19
+ title: string;
20
+ description: string;
21
+ locale: Language;
22
+ image?: string | undefined;
23
+ toolCss?: string | undefined;
24
+ publicPath: string;
25
+ manifestPath?: string | undefined;
26
+ alternates: readonly AlternateLink[];
27
+ }
28
+
29
+ const { title, description, locale, image, toolCss, publicPath, manifestPath, alternates } = Astro.props;
30
+ const brand = getBrand(locale);
31
+ const brandFavicon = getBrandAssetPath(brand, "favicon");
32
+ const brandSizedFavicon = getBrandAssetPath(brand, "faviconSized");
33
+ const brandAppleTouchIcon = getBrandAssetPath(brand, "appleTouchIcon");
34
+ const origin = brand === "jjlmoya" ? "https://www.jjlmoya.es" : "https://www.gamebob.dev";
35
+ const canonical = new URL(publicPath, origin).href;
36
+ const socialImage = image ? new URL(image, canonical).href : undefined;
37
+ const languageUi = LANGUAGE_UI[locale];
38
+ const absoluteUrl = (language: Language, url: string): string => {
39
+ if (import.meta.env.DEV) return new URL(url, "http://localhost:4324").pathname;
40
+ return new URL(
41
+ url,
42
+ language === "es" ? "https://www.jjlmoya.es" : "https://www.gamebob.dev",
43
+ ).href;
44
+ };
45
+ const languageLinks = alternates.map(({ language, url }) => ({
46
+ code: language,
47
+ name: LANGUAGE_NAMES[language],
48
+ href: absoluteUrl(language, url),
49
+ current: language === locale,
50
+ }));
51
+ const homeUrl = brand === "jjlmoya" ? "https://www.jjlmoya.es/" : `https://www.gamebob.dev/${locale}/`;
52
+ const openGraphLocale = LANGUAGE_LOCALES[locale].replace("-", "_");
53
+ const xDefaultUrl = alternates.find(({ language }) => language === "en")?.url ?? canonical;
54
+ ---
55
+
56
+ <!doctype html>
57
+ <html lang={locale} data-brand={brand}>
58
+ <head>
59
+ <meta charset="UTF-8" />
60
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
61
+ <ThemeBoot defaultTheme={brand === "gamebob" ? "theme-dark" : "theme-system"} />
62
+ <title>{title}</title>
63
+ <meta name="title" content={title} />
64
+ <meta name="description" content={description} />
65
+ <meta name="author" content="jjlmoya" />
66
+ <meta name="robots" content="index, follow, max-image-preview:large" />
67
+ <meta name="referrer" content="strict-origin-when-cross-origin" />
68
+ <link rel="icon" type="image/x-icon" href={brandFavicon} />
69
+ <link rel="icon" type="image/webp" sizes={brand === "jjlmoya" ? "32x32" : "48x48"} href={brandSizedFavicon} />
70
+ <link rel="apple-touch-icon" href={brandAppleTouchIcon} />
71
+ {manifestPath && <link rel="manifest" href={manifestPath} />}
72
+ <link rel="canonical" href={canonical} />
73
+ {alternates.map(({ language, url }) => <link rel="alternate" hreflang={language} href={absoluteUrl(language, url)} />)}
74
+ <link rel="alternate" hreflang="x-default" href={absoluteUrl("en", xDefaultUrl)} />
75
+ {toolCss && <link rel="stylesheet" href={toolCss} />}
76
+ <meta property="og:type" content="website" />
77
+ <meta property="og:url" content={canonical} />
78
+ <meta property="og:title" content={title} />
79
+ <meta property="og:description" content={description} />
80
+ <meta property="og:site_name" content={brand === "jjlmoya" ? "jjlmoya" : "GameBob Studio"} />
81
+ <meta property="og:locale" content={openGraphLocale} />
82
+ {socialImage && <meta property="og:image" content={socialImage} />}
83
+ {socialImage && <meta property="og:image:secure_url" content={socialImage} />}
84
+ {socialImage && <meta property="og:image:type" content="image/webp" />}
85
+ {socialImage && <meta property="og:image:width" content="1200" />}
86
+ {socialImage && <meta property="og:image:height" content="630" />}
87
+ {socialImage && <meta property="og:image:alt" content={description} />}
88
+ <meta name="twitter:card" content={socialImage ? "summary_large_image" : "summary"} />
89
+ <meta name="twitter:url" content={canonical} />
90
+ <meta name="twitter:title" content={title} />
91
+ <meta name="twitter:description" content={description} />
92
+ {socialImage && <meta name="twitter:image" content={socialImage} />}
93
+ <script is:inline type="application/ld+json" set:html={JSON.stringify({
94
+ "@context": "https://schema.org",
95
+ "@type": "WebPage",
96
+ url: canonical,
97
+ name: title,
98
+ description,
99
+ inLanguage: LANGUAGE_LOCALES[locale],
100
+ })} />
101
+ <script>
102
+ import { installEcosystemTracking } from "@jjlmoya/tracking";
103
+ installEcosystemTracking();
104
+ </script>
105
+ <slot name="head" />
106
+ </head>
107
+ <body class="production-page">
108
+ <Header
109
+ class="utilities-site-header"
110
+ identity={brand}
111
+ {homeUrl}
112
+ navigation={getSiteNavigation({ language: locale, activeSection: "utilities" })}
113
+ labels={HEADER_UI[locale]}
114
+ >
115
+ <LanguageTrigger slot="desktop-actions" label={languageUi.change} />
116
+ <LanguageTrigger slot="mobile-actions" label={languageUi.change} />
117
+ </Header>
118
+ <LanguageSheet links={languageLinks} title={languageUi.select} closeLabel={languageUi.close} />
119
+ <main><slot /></main>
120
+ <Footer class="utilities-site-footer" identity={brand} language={locale} />
121
+ </body>
122
+ </html>
123
+
124
+ <style is:global>
125
+ :root {
126
+ --science-accent: #10b981;
127
+ --science-accent-strong: #0d9488;
128
+ --color-brand-blue: #1ddabc;
129
+ --color-brand-dark: #000;
130
+ --color-brand-white: #fff;
131
+ --color-primary: var(--color-brand-blue);
132
+ --color-primary-rgb: 29, 218, 188;
133
+ --space-2xs: 0.125rem;
134
+ --space-xs: 0.25rem;
135
+ --space-sm: 0.5rem;
136
+ --space-base: 0.75rem;
137
+ --space-md: 1rem;
138
+ --space-md-lg: 1.25rem;
139
+ --space-lg: 1.5rem;
140
+ --space-xl: 2rem;
141
+ --space-1-5xl: 2.5rem;
142
+ --space-2xl: 3rem;
143
+ --space-3xl: 4rem;
144
+ --space-4xl: 6rem;
145
+ --space-3-5xl: 5rem;
146
+ --radius-base: 0.5rem;
147
+ --radius-lg: 1rem;
148
+ --radius-2xl: 2rem;
149
+ --radius-full: 9999px;
150
+ --font-family: inter, system-ui, sans-serif;
151
+ --font-weight-bold: 800;
152
+ --color-white: #fff;
153
+ }
154
+
155
+ :root,
156
+ .theme-light {
157
+ --bg-main: #f0f4f8;
158
+ --bg-page: #f0f4f8;
159
+ --bg-surface: #fff;
160
+ --bg-muted: #f1f5f9;
161
+ --text-main: #000;
162
+ --text-base: #000;
163
+ --text-muted: #4a5568;
164
+ --text-dimmed: #64748b;
165
+ --border-base: #e2e8f0;
166
+ --border-color: #e2e8f0;
167
+ --glass-bg: rgba(0, 0, 0, 0.05);
168
+ --glass-border: rgba(0, 0, 0, 0.1);
169
+ --shadow-base: rgba(0, 0, 0, 0.05);
170
+ --shadow-hover: rgba(0, 0, 0, 0.1);
171
+ --primary: var(--color-primary);
172
+ --primary-10: rgba(29, 218, 188, 0.1);
173
+ }
174
+
175
+ .theme-dark {
176
+ --bg-main: #000;
177
+ --bg-page: #000;
178
+ --bg-surface: #0f172a;
179
+ --bg-muted: #1e293b;
180
+ --text-main: #fff;
181
+ --text-base: #fff;
182
+ --text-muted: #94a3b8;
183
+ --text-dimmed: #64748b;
184
+ --border-base: #1e293b;
185
+ --border-color: #1e293b;
186
+ --glass-bg: rgba(255, 255, 255, 0.03);
187
+ --glass-border: rgba(255, 255, 255, 0.08);
188
+ --shadow-base: rgba(0, 0, 0, 0.3);
189
+ --shadow-hover: rgba(0, 0, 0, 0.4);
190
+ --primary: var(--color-primary);
191
+ --primary-10: rgba(29, 218, 188, 0.1);
192
+ }
193
+
194
+ *, *::before, *::after {
195
+ box-sizing: border-box;
196
+ }
197
+
198
+ html {
199
+ background: var(--bg-main);
200
+ color: var(--text-main);
201
+ scroll-behavior: smooth;
202
+ }
203
+
204
+ body.production-page {
205
+ min-width: 320px;
206
+ min-height: 100vh;
207
+ margin: 0;
208
+ padding-top: 4.5rem;
209
+ background: var(--bg-main);
210
+ color: var(--text-main);
211
+ font-family: var(--font-family, Inter, system-ui, sans-serif);
212
+ }
213
+
214
+ body.production-page main {
215
+ width: 100%;
216
+ min-height: 100vh;
217
+ }
218
+ </style>
@@ -0,0 +1,283 @@
1
+ ---
2
+ import ProductionPage from "./ProductionPage.astro";
3
+ import { FAQSection, UtilityHeader } from "@jjlmoya/utils-shared";
4
+ import { Icon } from "astro-icon/components";
5
+ import ProductionBreadcrumb from "../components/ProductionBreadcrumb.astro";
6
+ import ProductionWidget from "../components/ProductionWidget.astro";
7
+ import ProductionStructuredData from "../components/ProductionStructuredData.astro";
8
+ import type { KnownLocale, ToolLocaleContent } from "../types";
9
+ import { getCategoryUi } from "../mfe/category-ui";
10
+ import { getCategoryPath, getUtilityPath } from "../mfe/routes";
11
+ import { getUtilityCssPath, TABLETOP_ASSET_VERSION } from "../mfe/assets";
12
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
13
+
14
+ interface RelatedTool { title: string; description: string; href: string; icon: string; }
15
+ interface Props { locale: KnownLocale; content: ToolLocaleContent; englishSlug: string; categoryTitle: string; categorySlug: string; categoryHref: string; relatedTools: RelatedTool[]; Component: AstroComponentFactory; SEOComponent: AstroComponentFactory; BibliographyComponent: AstroComponentFactory; alternates: { language: KnownLocale; url: string }[]; image?: string | undefined; }
16
+ const { locale, content, englishSlug, categoryTitle, categorySlug, categoryHref, relatedTools, Component, SEOComponent, BibliographyComponent, alternates, image } = Astro.props;
17
+ const words = content.title.split(/\s+/).filter(Boolean);
18
+ const titleHighlight = words[0] ?? content.title;
19
+ const titleBase = words.slice(1).join(" ");
20
+ const publicPath = getUtilityPath(locale, categorySlug, content.slug);
21
+ const publicCategoryPath = getCategoryPath(locale, categorySlug);
22
+ const ui = getCategoryUi(locale);
23
+ ---
24
+
25
+ <ProductionPage title={content.title} description={content.description} {locale} {publicPath} manifestPath={`${publicPath}manifest.json?version=${TABLETOP_ASSET_VERSION}`} {alternates} {image} toolCss={getUtilityCssPath(englishSlug)}>
26
+ <div class="utility-production">
27
+ <div class="utility-production-inner">
28
+ <ProductionBreadcrumb locale={locale} categoryTitle={categoryTitle} categoryHref={categoryHref || publicCategoryPath} currentTitle={content.title} />
29
+ <UtilityHeader titleHighlight={titleHighlight} titleBase={titleBase} description={content.description} gradientFrom="#10b981" gradientTo="#0d9488" />
30
+ <section class="utility-tool-production"><Component ui={content.ui} locale={locale} /></section>
31
+ <div class="utility-zoom-production" aria-label={ui.zoomControls}>
32
+ <span class="utility-zoom-label">{ui.zoom}</span>
33
+ <div class="utility-zoom-controls"><button type="button" data-zoom="out" aria-label={ui.zoomOut}>−</button><button type="button" data-zoom="reset" aria-label={ui.resetZoom}>100%</button><button type="button" data-zoom="in" aria-label={ui.zoomIn}>+</button></div>
34
+ </div>
35
+ <ProductionWidget locale={locale} slug={content.slug} />
36
+ {content.faq.length > 0 && <section class="utility-section-production"><FAQSection items={content.faq} /></section>}
37
+ <Fragment slot="head">
38
+ <ProductionStructuredData locale={locale} {content} {publicPath} categoryTitle={categoryTitle} categoryHref={categoryHref || publicCategoryPath} />
39
+ </Fragment>
40
+ <section class="utility-section-production"><SEOComponent locale={locale} /></section>
41
+ {content.bibliography.length > 0 && <section class="utility-section-production"><BibliographyComponent locale={locale} /></section>}
42
+ {relatedTools.length > 0 && <section class="utility-related-production" aria-labelledby="related-tools-title"><div class="utility-related-heading"><span class="utility-related-eyebrow">{ui.relatedEyebrow}</span><h2 id="related-tools-title">{ui.moreToolsIn} {categoryTitle}</h2></div><div class="utility-related-grid">{relatedTools.map((tool, index) => <a href={tool.href} class="utility-related-card"><span class="utility-related-index">{String(index + 1).padStart(2, "0")}</span><span class="utility-related-icon"><Icon name={tool.icon} /></span><h3>{tool.title}</h3><p>{tool.description}</p><span class="utility-related-cta">{ui.openTool} <Icon name="mdi:arrow-top-right" /></span></a>)}</div></section>}
43
+ </div>
44
+ </div>
45
+ </ProductionPage>
46
+
47
+ <script>
48
+ const isWidget = new URLSearchParams(window.location.search).get("widget") === "true";
49
+ const page = document.querySelector<HTMLElement>(".utility-production");
50
+ const container = document.querySelector<HTMLElement>(".utility-tool-production");
51
+ const zoomButton = document.querySelector<HTMLButtonElement>('[data-zoom="reset"]');
52
+ const storedZoom = isWidget ? 1 : Number(localStorage.getItem("gamebob-utility-zoom") ?? 1);
53
+ let zoom = Number.isFinite(storedZoom) ? Math.min(1.5, Math.max(0.75, storedZoom)) : 1;
54
+ const applyZoom = () => {
55
+ if (container) container.style.zoom = String(zoom);
56
+ if (zoomButton) zoomButton.textContent = `${Math.round(zoom * 100)}%`;
57
+ };
58
+ document.querySelector('[data-zoom="out"]')?.addEventListener("click", () => { zoom = Math.max(0.75, zoom - 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
59
+ document.querySelector('[data-zoom="in"]')?.addEventListener("click", () => { zoom = Math.min(1.5, zoom + 0.1); applyZoom(); localStorage.setItem("gamebob-utility-zoom", String(zoom)); });
60
+ document.querySelector('[data-zoom="reset"]')?.addEventListener("click", () => { zoom = 1; applyZoom(); localStorage.removeItem("gamebob-utility-zoom"); });
61
+ if (isWidget) {
62
+ document.body.classList.add("utility-widget-body");
63
+ if (page) {
64
+ page.classList.add("utility-widget-mode");
65
+ }
66
+ }
67
+ applyZoom();
68
+ </script>
69
+
70
+ <style>
71
+ .utility-production {
72
+ min-height: 100vh;
73
+ overflow-x: clip;
74
+ background: radial-gradient(ellipse at top, var(--primary-10), var(--bg-surface) 50%, var(--bg-main));
75
+ }
76
+
77
+ .utility-production.utility-widget-mode {
78
+ min-height: auto;
79
+ background: transparent;
80
+ }
81
+
82
+ .utility-production.utility-widget-mode .utility-production-inner {
83
+ max-width: none;
84
+ padding: 0;
85
+ }
86
+
87
+ .utility-production.utility-widget-mode .utility-tool-production {
88
+ margin-top: 0;
89
+ }
90
+
91
+ .utility-production.utility-widget-mode .utility-production-inner > :not(.utility-tool-production) {
92
+ display: none;
93
+ }
94
+
95
+ :global(body.utility-widget-body) {
96
+ margin: 0;
97
+ padding-top: 0;
98
+ background: transparent;
99
+ }
100
+
101
+ :global(body.utility-widget-body > :not(main)) {
102
+ display: none;
103
+ }
104
+
105
+ :global(body.utility-widget-body main), :global(body.utility-widget-body main .utility-production), :global(body.utility-widget-body main .utility-production-inner) {
106
+ min-height: auto;
107
+ }
108
+
109
+ .utility-production-inner {
110
+ max-width: 72rem;
111
+ margin: 0 auto;
112
+ padding: var(--space-xl) var(--space-lg) var(--space-4xl);
113
+ }
114
+
115
+ .utility-tool-production {
116
+ width: 100%;
117
+ margin-top: var(--space-2xl);
118
+ }
119
+
120
+ .utility-zoom-production {
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ gap: 0.75rem;
125
+ margin: var(--space-xl) auto 0;
126
+ color: var(--text-muted);
127
+ font-size: 0.8rem;
128
+ font-weight: 700;
129
+ letter-spacing: 0.08em;
130
+ text-transform: uppercase;
131
+ }
132
+
133
+ .utility-zoom-controls {
134
+ display: inline-flex;
135
+ overflow: hidden;
136
+ border: 1px solid var(--border-base);
137
+ border-radius: 0.75rem;
138
+ background: color-mix(in srgb, var(--bg-surface) 90%, transparent);
139
+ box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--shadow-base, #000) 35%, transparent);
140
+ }
141
+
142
+ .utility-zoom-production button {
143
+ min-width: 2.5rem;
144
+ border: 0;
145
+ border-right: 1px solid var(--border-base);
146
+ padding: 0.5rem 0.75rem;
147
+ background: transparent;
148
+ color: var(--text-main);
149
+ cursor: pointer;
150
+ font-size: 1rem;
151
+ }
152
+ .utility-zoom-production button:last-child { border-right: 0; }
153
+
154
+ .utility-zoom-production button:hover,
155
+ .utility-zoom-production button:focus-visible {
156
+ background: color-mix(in srgb, var(--science-accent) 12%, transparent);
157
+ color: var(--science-accent);
158
+ }
159
+
160
+ .utility-section-production, .utility-related-production {
161
+ max-width: 56rem;
162
+ margin: var(--space-4xl) auto 0;
163
+ }
164
+
165
+ .utility-section-production {
166
+ font-size: 1.125rem;
167
+ line-height: 1.75;
168
+ }
169
+
170
+ .utility-related-heading {
171
+ margin-bottom: var(--space-lg);
172
+ text-align: center;
173
+ }
174
+
175
+ .utility-related-eyebrow {
176
+ display: block;
177
+ margin-bottom: 0.45rem;
178
+ color: var(--science-accent);
179
+ font-size: 0.75rem;
180
+ font-weight: 800;
181
+ letter-spacing: 0.14em;
182
+ text-transform: uppercase;
183
+ }
184
+
185
+ .utility-related-production h2 {
186
+ margin: 0;
187
+ color: var(--text-main);
188
+ font-size: clamp(1.75rem, 4vw, 2.5rem);
189
+ }
190
+
191
+ .utility-related-grid {
192
+ display: grid;
193
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
194
+ gap: 1rem;
195
+ }
196
+
197
+ .utility-related-card {
198
+ position: relative;
199
+ display: flex;
200
+ min-height: 238px;
201
+ flex-direction: column;
202
+ overflow: hidden;
203
+ padding: 1.25rem;
204
+ border: 1px solid color-mix(in srgb, var(--science-accent) 18%, var(--border-base));
205
+ border-radius: 1rem;
206
+ background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--science-accent) 4%, var(--bg-surface)));
207
+ color: inherit;
208
+ text-decoration: none;
209
+ box-shadow: 0 0.75rem 1.75rem color-mix(in srgb, var(--shadow-base, #000) 30%, transparent);
210
+ transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
211
+ }
212
+
213
+ .utility-related-card:hover,
214
+ .utility-related-card:focus-visible {
215
+ border-color: var(--science-accent);
216
+ box-shadow: 0 1rem 2rem color-mix(in srgb, var(--science-accent) 16%, transparent);
217
+ transform: translateY(-3px);
218
+ }
219
+
220
+ .utility-related-card:focus-visible {
221
+ outline: 3px solid color-mix(in srgb, var(--science-accent) 55%, transparent);
222
+ outline-offset: 3px;
223
+ }
224
+
225
+ .utility-related-index {
226
+ position: absolute;
227
+ top: 1.25rem;
228
+ right: 1.25rem;
229
+ color: var(--text-dimmed, var(--text-muted));
230
+ font-family: ui-monospace, SFMono-Regular, monospace;
231
+ font-size: 0.7rem;
232
+ letter-spacing: 0.12em;
233
+ }
234
+
235
+ .utility-related-icon {
236
+ display: grid;
237
+ width: 2.75rem;
238
+ height: 2.75rem;
239
+ place-items: center;
240
+ margin-bottom: 1rem;
241
+ border: 1px solid color-mix(in srgb, var(--science-accent) 28%, transparent);
242
+ border-radius: 0.8rem;
243
+ background: color-mix(in srgb, var(--science-accent) 12%, var(--bg-surface));
244
+ color: var(--science-accent);
245
+ font-size: 1.35rem;
246
+ }
247
+
248
+ .utility-related-card h3 {
249
+ margin: 0 0 0.5rem;
250
+ color: var(--text-main);
251
+ font-size: 1rem;
252
+ }
253
+
254
+ .utility-related-card p {
255
+ margin: 0 0 1rem;
256
+ color: var(--text-muted);
257
+ font-size: 0.9rem;
258
+ line-height: 1.5;
259
+ }
260
+
261
+ .utility-related-cta {
262
+ display: inline-flex;
263
+ align-items: center;
264
+ gap: 0.4rem;
265
+ margin-top: auto;
266
+ color: var(--science-accent);
267
+ font-size: 0.75rem;
268
+ font-weight: 800;
269
+ letter-spacing: 0.08em;
270
+ text-transform: uppercase;
271
+ }
272
+
273
+ .utility-related-cta svg {
274
+ width: 1rem;
275
+ height: 1rem;
276
+ transition: transform 0.2s ease;
277
+ }
278
+ .utility-related-card:hover .utility-related-cta svg { transform: translate(2px, -2px); }
279
+
280
+ @media (width <= 640px) {
281
+ .utility-production-inner { padding: var(--space-lg) var(--space-sm) var(--space-3xl); }
282
+ }
283
+ </style>