@jjlmoya/utils-drones 1.36.0 → 1.38.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 (72) hide show
  1. package/package.json +17 -5
  2. package/scripts/postinstall.mjs +2 -4
  3. package/src/category/index.ts +4 -0
  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/entries.ts +4 -0
  8. package/src/i18n/header-ui.ts +15 -0
  9. package/src/i18n/language-ui.ts +9 -0
  10. package/src/i18n/languages.ts +24 -0
  11. package/src/identity/brands.ts +5 -0
  12. package/src/layouts/ProductionCategoryPage.astro +184 -0
  13. package/src/layouts/ProductionPage.astro +218 -0
  14. package/src/layouts/ProductionUtilityPage.astro +283 -0
  15. package/src/mfe/assets.ts +39 -0
  16. package/src/mfe/category-ui.ts +34 -0
  17. package/src/mfe/manifest.ts +45 -0
  18. package/src/mfe/routes.ts +50 -0
  19. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug]/manifest.json.ts +49 -0
  20. package/src/pages/[locale]/[utilities]/[categories]/[category]/[slug].astro +100 -0
  21. package/src/pages/[locale]/[utilities]/[categories]/[category].astro +44 -0
  22. package/src/pages/index.astro +3 -4
  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/locale_completeness.test.ts +2 -2
  29. package/src/tests/mfe_cache_contract.test.ts +11 -0
  30. package/src/tests/mfe_manifest.test.ts +28 -0
  31. package/src/tests/registry_contract.test.ts +67 -0
  32. package/src/tests/script_density.test.ts +1 -1
  33. package/src/tests/seo_length.test.ts +1 -0
  34. package/src/tests/tool_validation.test.ts +2 -2
  35. package/src/tests/translation_copy.test.ts +2 -2
  36. package/src/tool/antenna-length-calculator/index.ts +1 -0
  37. package/src/tool/drone-flight-time/index.ts +1 -0
  38. package/src/tool/drone-mission-battery-reserve-planner/bibliography.astro +6 -0
  39. package/src/tool/drone-mission-battery-reserve-planner/bibliography.ts +7 -0
  40. package/src/tool/drone-mission-battery-reserve-planner/component.astro +229 -0
  41. package/src/tool/drone-mission-battery-reserve-planner/controller.ts +124 -0
  42. package/src/tool/drone-mission-battery-reserve-planner/dom-views.ts +114 -0
  43. package/src/tool/drone-mission-battery-reserve-planner/drone-mission-battery-reserve-planner.css +392 -0
  44. package/src/tool/drone-mission-battery-reserve-planner/entry.ts +27 -0
  45. package/src/tool/drone-mission-battery-reserve-planner/evaluator.ts +23 -0
  46. package/src/tool/drone-mission-battery-reserve-planner/i18n/de.ts +223 -0
  47. package/src/tool/drone-mission-battery-reserve-planner/i18n/en.ts +223 -0
  48. package/src/tool/drone-mission-battery-reserve-planner/i18n/es.ts +223 -0
  49. package/src/tool/drone-mission-battery-reserve-planner/i18n/fr.ts +223 -0
  50. package/src/tool/drone-mission-battery-reserve-planner/i18n/id.ts +223 -0
  51. package/src/tool/drone-mission-battery-reserve-planner/i18n/it.ts +223 -0
  52. package/src/tool/drone-mission-battery-reserve-planner/i18n/ja.ts +223 -0
  53. package/src/tool/drone-mission-battery-reserve-planner/i18n/ko.ts +223 -0
  54. package/src/tool/drone-mission-battery-reserve-planner/i18n/nl.ts +223 -0
  55. package/src/tool/drone-mission-battery-reserve-planner/i18n/pl.ts +223 -0
  56. package/src/tool/drone-mission-battery-reserve-planner/i18n/pt.ts +223 -0
  57. package/src/tool/drone-mission-battery-reserve-planner/i18n/ru.ts +223 -0
  58. package/src/tool/drone-mission-battery-reserve-planner/i18n/sv.ts +223 -0
  59. package/src/tool/drone-mission-battery-reserve-planner/i18n/tr.ts +223 -0
  60. package/src/tool/drone-mission-battery-reserve-planner/i18n/zh.ts +223 -0
  61. package/src/tool/drone-mission-battery-reserve-planner/index.ts +11 -0
  62. package/src/tool/drone-mission-battery-reserve-planner/logic.test.ts +94 -0
  63. package/src/tool/drone-mission-battery-reserve-planner/logic.ts +203 -0
  64. package/src/tool/drone-mission-battery-reserve-planner/seo.astro +15 -0
  65. package/src/tool/drone-mission-battery-reserve-planner/storage.ts +59 -0
  66. package/src/tool/drone-mission-battery-reserve-planner/ui.ts +78 -0
  67. package/src/tool/gps-coordinates-converter/index.ts +1 -0
  68. package/src/tools.ts +4 -0
  69. package/src/types.ts +4 -6
  70. package/src/worker.ts +25 -0
  71. package/src/pages/[locale]/[slug].astro +0 -161
  72. 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(--drones-accent) 18%, var(--border-base));
90
+ border-radius: 1.25rem;
91
+ background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--drones-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(--drones-accent);
101
+ box-shadow: 0 1.25rem 2.5rem color-mix(in srgb, var(--drones-accent) 18%, transparent);
102
+ transform: translateY(-4px);
103
+ }
104
+
105
+ .category-tool-card:focus-visible {
106
+ outline: 3px solid color-mix(in srgb, var(--drones-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(--drones-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(--drones-accent) 28%, transparent);
131
+ border-radius: 1rem;
132
+ background: color-mix(in srgb, var(--drones-accent) 12%, var(--bg-surface));
133
+ color: var(--drones-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(--drones-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
+ --drones-accent: #10b981;
127
+ --drones-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(--drones-accent) 12%, transparent);
157
+ color: var(--drones-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(--drones-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(--drones-accent) 18%, var(--border-base));
205
+ border-radius: 1rem;
206
+ background: linear-gradient(145deg, var(--bg-surface), color-mix(in srgb, var(--drones-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(--drones-accent);
216
+ box-shadow: 0 1rem 2rem color-mix(in srgb, var(--drones-accent) 16%, transparent);
217
+ transform: translateY(-3px);
218
+ }
219
+
220
+ .utility-related-card:focus-visible {
221
+ outline: 3px solid color-mix(in srgb, var(--drones-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(--drones-accent) 28%, transparent);
242
+ border-radius: 0.8rem;
243
+ background: color-mix(in srgb, var(--drones-accent) 12%, var(--bg-surface));
244
+ color: var(--drones-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(--drones-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>