@brightsideoy/kama 0.1.0-alpha.113 → 0.1.0-alpha.115

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 (60) hide show
  1. package/bin/cli.js +172 -379
  2. package/content/definitions/blocks/button.yaml +7 -8
  3. package/content/definitions/blocks/header.yaml +7 -9
  4. package/content/definitions/blocks/header_image.yaml +14 -15
  5. package/content/definitions/blocks/hero.yaml +22 -21
  6. package/content/definitions/blocks/image_card.yaml +46 -47
  7. package/content/definitions/blocks/single_image.yaml +19 -20
  8. package/content/definitions/blocks/text.yaml +8 -9
  9. package/content/definitions/channels/ecommerce.yaml +14 -16
  10. package/content/definitions/channels/website.yaml +20 -21
  11. package/content/definitions/field-sets/access.yaml +3 -5
  12. package/content/definitions/field-sets/channel-base.yaml +25 -25
  13. package/content/definitions/field-sets/content-block.yaml +36 -41
  14. package/content/definitions/field-sets/page-base.yaml +40 -45
  15. package/content/definitions/field-sets/page-meta.yaml +1 -4
  16. package/content/definitions/field-sets/page-social.yaml +6 -8
  17. package/content/definitions/field-sets/tree-base.yaml +25 -25
  18. package/content/definitions/nodes/address.yaml +29 -21
  19. package/content/definitions/nodes/article.yaml +24 -19
  20. package/content/definitions/nodes/event.yaml +32 -26
  21. package/content/definitions/nodes/external-link.yaml +20 -17
  22. package/content/definitions/nodes/page.yaml +38 -35
  23. package/content/definitions/nodes/person.yaml +29 -27
  24. package/content/definitions/nodes/product.yaml +52 -49
  25. package/content/definitions/trees/navigation.yaml +26 -21
  26. package/content/definitions/trees/social-links.yaml +5 -2
  27. package/dist/assets/index-8T39DEP3.js +162 -0
  28. package/dist/assets/index-B9oh2_TL.css +1 -0
  29. package/dist/index.html +2 -2
  30. package/dist/index.js +6492 -4249
  31. package/package.json +5 -6
  32. package/schema.sql +5 -13
  33. package/templates/astro-starter/astro.config.mjs +40 -0
  34. package/templates/astro-starter/package.json.template +20 -0
  35. package/templates/astro-starter/src/components/blocks/BlockRenderer.astro +125 -0
  36. package/templates/astro-starter/src/components/blocks/ColumnLayout.astro +66 -0
  37. package/templates/astro-starter/src/components/blocks/HeaderImage.astro +131 -0
  38. package/templates/astro-starter/src/components/blocks/Hero.astro +218 -0
  39. package/templates/astro-starter/src/components/blocks/ImageBlock.astro +160 -0
  40. package/templates/astro-starter/src/components/blocks/TextBlock.astro +13 -0
  41. package/templates/astro-starter/src/components/global/Breadcrumb.astro +62 -0
  42. package/templates/astro-starter/src/components/global/Footer.astro +48 -0
  43. package/templates/astro-starter/src/components/global/Header.astro +92 -0
  44. package/templates/astro-starter/src/components/global/NavigationMenu.astro +450 -0
  45. package/templates/astro-starter/src/components/ui/KamaImage.astro +148 -0
  46. package/templates/astro-starter/src/kama-image-service.ts +41 -0
  47. package/templates/astro-starter/src/layouts/Layout.astro +280 -0
  48. package/templates/astro-starter/src/lib/cms.ts +149 -0
  49. package/templates/astro-starter/src/lib/media-url.ts +34 -0
  50. package/templates/astro-starter/src/middleware.ts +306 -0
  51. package/templates/astro-starter/src/pages/404.astro +46 -0
  52. package/templates/astro-starter/src/pages/[...slug].astro +234 -0
  53. package/templates/astro-starter/src/pages/media/[...path].ts +113 -0
  54. package/templates/astro-starter/tsconfig.json +0 -0
  55. package/test-local.sh +76 -0
  56. package/dist/assets/index-10GEw0KY.css +0 -1
  57. package/dist/assets/index-THJ6BqPP.js +0 -1394
  58. package/kama-worker/bundled-definitions.js +0 -2394
  59. package/kama-worker/bundled-definitions.ts +0 -2395
  60. package/scripts/bundle-definitions.js +0 -224
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brightsideoy/kama",
3
- "version": "0.1.0-alpha.113",
3
+ "version": "0.1.0-alpha.115",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -15,15 +15,14 @@
15
15
  "create-kama": "bin/cli.js"
16
16
  },
17
17
  "scripts": {
18
- "predev": "npm run build:schema && npm run bundle:definitions",
19
- "dev": "concurrently \"vite\" \"tsx watch server/dev.ts\"",
20
- "bundle:definitions": "node scripts/bundle-definitions.js",
21
18
  "build:schema": "tsx src/db/generate-schema.ts",
22
- "build:server": "npm run bundle:definitions && vite build",
19
+ "build:server": "vite build",
23
20
  "build:ui": "vite build --config vite.config.ui.ts",
24
21
  "build": "npm run build:schema && npm run build:server && npm run build:ui",
25
22
  "build:cms": "npm run build",
26
- "prepublishOnly": "npm run build",
23
+ "predev": "npm run build:schema",
24
+ "dev": "concurrently \"vite\" \"tsx watch server/dev.ts\"",
25
+ "prepack": "npm run build",
27
26
  "serve": "tsx server/dev.ts"
28
27
  },
29
28
  "publishConfig": {
package/schema.sql CHANGED
@@ -205,17 +205,9 @@ CREATE INDEX IF NOT EXISTS idx_assets_deleted ON assets(deletedAt);
205
205
  CREATE INDEX IF NOT EXISTS idx_assets_parent ON assets(parentAssetId);
206
206
 
207
207
  -- System Defaults
208
- INSERT OR IGNORE INTO channels (id, key, label, channelType, defaultLanguage, isHidden, dataJson) VALUES (1, 'default-channel', 'Default Channel', 'website', 'en', 0, '{}');
209
- INSERT OR IGNORE INTO trees (id, key, label, treeType, channelId, isHidden, dataJson) VALUES (1, 'default-tree', 'Default Navigation', 'navigation', 1, 0, '{}');
208
+ INSERT OR IGNORE INTO channels (id, key, label, channelType, defaultLanguage, isHidden, dataJson) VALUES (1, 'website', 'Website', 'website', 'en', 0, '{}');
209
+ INSERT OR IGNORE INTO trees (id, key, label, treeType, channelId, isHidden, dataJson) VALUES (1, 'main-navigation', 'Main Navigation', 'navigation', 1, 0, '{}');
210
210
  INSERT OR IGNORE INTO channel_trees (channelId, treeId, key) VALUES (1, 1, 'main');
211
-
212
-
213
- -- Native Columns
214
- CREATE INDEX IF NOT EXISTS idx_content_nodes_slug ON content_nodes (slug);
215
- CREATE INDEX IF NOT EXISTS idx_content_nodes_contentType ON content_nodes (contentType);
216
- CREATE INDEX IF NOT EXISTS idx_content_nodes_updatedAt ON content_nodes (updatedAt);
217
-
218
- -- JSON Custom Fields
219
- CREATE INDEX IF NOT EXISTS idx_content_nodes_articleDate ON content_nodes (json_extract(dataJson, '$.articleDate'));
220
- CREATE INDEX IF NOT EXISTS idx_content_nodes_startTime ON content_nodes (json_extract(dataJson, '$.startTime'));
221
- CREATE INDEX IF NOT EXISTS idx_content_nodes_endTime ON content_nodes (json_extract(dataJson, '$.endTime'));
211
+ INSERT OR IGNORE INTO content_nodes (id, contentType, title, slug, language, status, dataJson, draftDataJson, publishedDataJson, publishedAt) VALUES (1, 'page', 'Global Content Delivery at the Edge', 'home', 'en', 'published', '{"titleSuffix":"– KAMA"}', '{"titleSuffix":"– KAMA"}', '{"titleSuffix":"– KAMA"}', CURRENT_TIMESTAMP);
212
+ INSERT OR IGNORE INTO node_channels (nodeId, channelId) VALUES (1, 1);
213
+ INSERT OR IGNORE INTO tree_nodes (id, treeId, contentId, parentId, sortOrder) VALUES (1, 1, 1, NULL, 0);
@@ -0,0 +1,40 @@
1
+ // templates/astro-starter/astro.config.mjs
2
+ import { defineConfig } from 'astro/config';
3
+ import cloudflare from '@astrojs/cloudflare';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+
9
+ export default defineConfig({
10
+ output: 'server',
11
+ trailingSlash: 'ignore',
12
+
13
+ // 🎯 Use Kama Image Service
14
+ image: {
15
+ service: {
16
+ entrypoint: './src/kama-image-service.ts',
17
+ }
18
+ },
19
+
20
+ // 🎯 Passthrough allows Kama proxy to take 100% control
21
+ adapter: cloudflare({
22
+ imageService: 'passthrough',
23
+ }),
24
+
25
+ vite: {
26
+ resolve: {
27
+ alias: {
28
+ '@': path.resolve(__dirname, './src')
29
+ }
30
+ },
31
+ optimizeDeps: {
32
+ exclude: ['cloudflare:workers', '@astrojs/cloudflare', 'astro']
33
+ },
34
+ ssr: {
35
+ target: 'webworker',
36
+ noExternal: true,
37
+ external: ['cloudflare:workers', 'node:process']
38
+ }
39
+ }
40
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "{{SITE_NAME}}",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "engines": {
6
+ "node": ">=22.12.0"
7
+ },
8
+ "scripts": {
9
+ "dev": "astro dev",
10
+ "build": "astro build",
11
+ "preview": "astro preview",
12
+ "deploy": "astro build && wrangler deploy"
13
+ },
14
+ "dependencies": {
15
+ "@astrojs/cloudflare": "^14.2.5",
16
+ "@brightsideoy/kama": "{{KAMA_VERSION}}",
17
+ "astro": "^7.2.0",
18
+ "wrangler": "^4.0.0"
19
+ }
20
+ }
@@ -0,0 +1,125 @@
1
+ ---
2
+ // src/components/blocks/BlockRenderer.astro
3
+ import ImageBlock from "@/components/blocks/ImageBlock.astro";
4
+ import HeaderImage from "@/components/blocks/HeaderImage.astro";
5
+ import ColumnLayout from "@/components/blocks/ColumnLayout.astro";
6
+
7
+ interface Block {
8
+ instanceId: number;
9
+ blockId?: string;
10
+ componentId: string;
11
+ data: Record<string, any>;
12
+ }
13
+
14
+ interface Props {
15
+ blocks?: Block[];
16
+ contentMap?: Record<string, any[]>;
17
+ region?: string;
18
+ }
19
+
20
+ const { blocks = [], contentMap = {}, region = "main" } = Astro.props;
21
+ const safeBlocks = Array.isArray(blocks) ? blocks : [];
22
+
23
+ const visibleBlocks = safeBlocks.filter(b => {
24
+ if (!b || (!b.data && !b.componentId?.includes("column"))) return false;
25
+
26
+ const blockData = b.data || {};
27
+ if (
28
+ blockData.hidden === true ||
29
+ blockData.hidden === 1 ||
30
+ String(blockData.hidden) === "true"
31
+ ) {
32
+ return false;
33
+ }
34
+
35
+ const now = new Date();
36
+ if (blockData.startShowingAt) {
37
+ const start = new Date(blockData.startShowingAt);
38
+ if (!isNaN(start.getTime()) && now < start) return false;
39
+ }
40
+ if (blockData.stopShowingAt) {
41
+ const stop = new Date(blockData.stopShowingAt);
42
+ if (!isNaN(stop.getTime()) && now > stop) return false;
43
+ }
44
+
45
+ return true;
46
+ });
47
+ ---
48
+
49
+ {
50
+ visibleBlocks.map((block, index) => {
51
+ const data = block.data || {};
52
+ const blockId = block.blockId || data.blockId || "";
53
+ const componentId = (block.componentId || "").toLowerCase();
54
+
55
+ // Check for any column layout ending in column or two-column
56
+ if (componentId.includes("column")) {
57
+ return <ColumnLayout blockId={blockId} contentMap={contentMap} />;
58
+ }
59
+
60
+ switch (componentId) {
61
+ case "header":
62
+ case "headline":
63
+ case "header-block":
64
+ case "header_block": {
65
+ const headline = data.headline || data.title || "";
66
+ const subhead = data.subhead || data.body || "";
67
+ return (
68
+ <header class="cms-block cms-block-header">
69
+ {headline && <Fragment set:html={headline} />}
70
+ {subhead && <Fragment set:html={subhead} />}
71
+ </header>
72
+ );
73
+ }
74
+
75
+ case "text":
76
+ case "text-block":
77
+ case "text_block": {
78
+ const bodyText = data.body || data.text || data.content || "";
79
+ return (
80
+ <div class="cms-block cms-block-text">
81
+ <Fragment set:html={bodyText} />
82
+ </div>
83
+ );
84
+ }
85
+
86
+ case "header-image":
87
+ case "header_image": {
88
+ return (
89
+ <HeaderImage data={data} region={region} isFirst={index === 0} />
90
+ );
91
+ }
92
+
93
+ // 🎯 Handles all image component identifiers from the payload
94
+ case "image":
95
+ case "image-block":
96
+ case "image_block":
97
+ case "single_image":
98
+ case "single-image": {
99
+ return <ImageBlock data={data} region={region} isFirst={index === 0} />;
100
+ }
101
+
102
+ default:
103
+ return import.meta.env.DEV ? (
104
+ <div class="unhandled-block">
105
+ <p>Unhandled component: {block.componentId}</p>
106
+ </div>
107
+ ) : null;
108
+ }
109
+ })
110
+ }
111
+
112
+ <style>
113
+ .cms-block {
114
+ margin-bottom: 1.5rem;
115
+ }
116
+
117
+ .unhandled-block {
118
+ padding: 12px 16px;
119
+ border: 1px dashed #cbd5e1;
120
+ background: #f8fafc;
121
+ color: #64748b;
122
+ font-size: 13px;
123
+ margin-bottom: 1.5rem;
124
+ }
125
+ </style>
@@ -0,0 +1,66 @@
1
+ ---
2
+ // src/components/blocks/ColumnLayout.astro
3
+ import BlockRenderer from "@/components/blocks/BlockRenderer.astro";
4
+
5
+ interface Props {
6
+ blockId: string;
7
+ contentMap: Record<string, any[]>;
8
+ }
9
+
10
+ const { blockId, contentMap } = Astro.props;
11
+
12
+ const order = ["left", "middle", "right", "col1", "col2", "col3", "col4"];
13
+
14
+ // 1. Find all sub-regions belonging to this specific layout instance
15
+ const regionKeys = Object.keys(contentMap).filter(
16
+ key => blockId && key.startsWith(`${blockId}_`),
17
+ );
18
+
19
+ // 2. Sort keys logically (left -> middle -> right or col1 -> col2 -> col3)
20
+ const sortedKeys = regionKeys.sort((a, b) => {
21
+ const suffixA = a.replace(`${blockId}_`, "");
22
+ const suffixB = b.replace(`${blockId}_`, "");
23
+ const idxA = order.indexOf(suffixA);
24
+ const idxB = order.indexOf(suffixB);
25
+
26
+ if (idxA === -1 && idxB === -1) return a.localeCompare(b);
27
+ if (idxA === -1) return 1;
28
+ if (idxB === -1) return -1;
29
+ return idxA - idxB;
30
+ });
31
+
32
+ const columnCount = Math.max(sortedKeys.length, 1);
33
+ ---
34
+
35
+ <div class="cms-column-layout" style={`--col-count: ${columnCount}`}>
36
+ {
37
+ sortedKeys.map(key => {
38
+ const columnBlocks = contentMap[key] || [];
39
+ return (
40
+ <div class="column">
41
+ <!-- 🎯 Passes region={key} down to BlockRenderer -->
42
+ <BlockRenderer
43
+ blocks={columnBlocks}
44
+ contentMap={contentMap}
45
+ region={key}
46
+ />
47
+ </div>
48
+ );
49
+ })
50
+ }
51
+ </div>
52
+
53
+ <style>
54
+ .cms-column-layout {
55
+ display: grid;
56
+ gap: 2rem;
57
+ margin-bottom: 1.5rem;
58
+ grid-template-columns: 1fr;
59
+ }
60
+
61
+ @media (min-width: 768px) {
62
+ .cms-column-layout {
63
+ grid-template-columns: repeat(var(--col-count, 1), 1fr);
64
+ }
65
+ }
66
+ </style>
@@ -0,0 +1,131 @@
1
+ ---
2
+ // src/components/blocks/HeaderImage.astro
3
+ import KamaImage from "@/components/ui/KamaImage.astro";
4
+
5
+ interface Props {
6
+ data: {
7
+ image?: any;
8
+ image_meta?: any;
9
+ width?: number | string;
10
+ height?: number | string;
11
+ };
12
+ region?: string;
13
+ isFirst?: boolean;
14
+ }
15
+
16
+ const { data, region = "main", isFirst = false } = Astro.props;
17
+
18
+ const img = data?.image;
19
+
20
+ if (!img) {
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Determine if this image is likely to be the Largest Contentful Paint.
26
+ */
27
+ const isLcp = region === "header" || (region === "main" && isFirst);
28
+
29
+ /**
30
+ * Determine grid column count from the region name.
31
+ */
32
+ let columnCount = 1;
33
+
34
+ if (region.includes("_")) {
35
+ columnCount = 2;
36
+
37
+ if (region.includes("_middle") || region.includes("_col3")) {
38
+ columnCount = 3;
39
+ }
40
+
41
+ if (region.includes("_col4")) {
42
+ columnCount = 4;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Default single-column layout.
48
+ */
49
+ let maxWidth = "1352";
50
+ let targetColumnWidth = 1352;
51
+
52
+ // 1-col: Base mobile (~400), 2x mobile/tablet (~800), 1x desktop (1352), 2x desktop (2704)
53
+ let optimizedWidths = [400, 800, 1352, 2048, 2704];
54
+ let sizes = "(max-width: 1399px) calc(100vw - 48px), 1352px";
55
+
56
+ /**
57
+ * Two-column layout.
58
+ */
59
+ if (columnCount === 2) {
60
+ maxWidth = "660";
61
+ targetColumnWidth = 660;
62
+
63
+ // 2-col: 1x desktop (660), 2x mobile stacked (~800), 2x desktop (1320)
64
+ optimizedWidths = [400, 660, 800, 1320];
65
+ sizes =
66
+ "(max-width: 767px) calc(100vw - 48px), " +
67
+ "(max-width: 1399px) calc((100vw - 80px) / 2), " +
68
+ "660px";
69
+ }
70
+
71
+ /**
72
+ * Three-column layout.
73
+ */
74
+ else if (columnCount === 3) {
75
+ maxWidth = "429";
76
+ targetColumnWidth = 429;
77
+
78
+ // 3-col: 1x desktop (429), 2x desktop/tablet (~858), 2x/3x mobile stacked (~1000)
79
+ optimizedWidths = [300, 429, 858, 1024];
80
+ sizes =
81
+ "(max-width: 767px) calc(100vw - 48px), " +
82
+ "(max-width: 1399px) calc((100vw - 112px) / 3), " +
83
+ "429px";
84
+ }
85
+
86
+ /**
87
+ * Four-column layout.
88
+ */
89
+ else if (columnCount === 4) {
90
+ maxWidth = "314";
91
+ targetColumnWidth = 314;
92
+
93
+ // 4-col: 1x desktop (314), 2x desktop (628), 2x/3x mobile stacked (~1000)
94
+ optimizedWidths = [264, 314, 628, 1024];
95
+ sizes =
96
+ "(max-width: 767px) calc(100vw - 48px), " +
97
+ "(max-width: 1399px) calc((100vw - 144px) / 4), " +
98
+ "314px";
99
+ }
100
+ ---
101
+
102
+ <div class="cms-block cms-block-header-image" style={`--max-w: ${maxWidth}px;`}>
103
+ <KamaImage
104
+ image={img}
105
+ sizes={sizes}
106
+ desktopWidths={optimizedWidths}
107
+ idealWidth={targetColumnWidth}
108
+ width={data.width || data.image_meta?.width}
109
+ height={data.height || data.image_meta?.height}
110
+ class="responsive-header-image"
111
+ loading={isLcp ? "eager" : "lazy"}
112
+ fetchpriority={isLcp ? "high" : "auto"}
113
+ decoding={isLcp ? "sync" : "async"}
114
+ />
115
+ </div>
116
+
117
+ <style>
118
+ .cms-block-header-image {
119
+ width: 100%;
120
+ max-width: var(--max-w, 1352px);
121
+ margin: 0 auto 1.5rem auto;
122
+ }
123
+
124
+ :global(.responsive-header-image) {
125
+ width: 100%;
126
+ height: auto;
127
+ display: block;
128
+ border-radius: var(--cms-radius-md, 0.5rem);
129
+ max-width: 100%;
130
+ }
131
+ </style>
@@ -0,0 +1,218 @@
1
+ ---
2
+ // src/components/blocks/Hero.astro
3
+ ---
4
+
5
+ <section class="hero-container">
6
+ <div class="hero-content">
7
+ <!-- Sub-badge -->
8
+ <div class="hero-badge">
9
+ <span class="badge-dot"></span>
10
+ Freeware for Education & NGOs
11
+ </div>
12
+
13
+ <!-- H1 Title with Slogan -->
14
+ <h1 class="hero-title">KAMA – Headless CMS on Edge</h1>
15
+
16
+ <!-- Subtitle -->
17
+ <p class="hero-subtitle">
18
+ Deploy sub-millisecond corporate web experiences with zero infrastructure
19
+ overhead. Built natively on Astro, Hono, and Cloudflare Workers (D1/R2).
20
+ </p>
21
+
22
+ <!-- Call to Action Buttons -->
23
+ <div class="hero-actions">
24
+ <a href="/docs/quick-start" class="btn btn-primary">
25
+ Get Started
26
+ <svg
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ width="16"
29
+ height="16"
30
+ viewBox="0 0 24 24"
31
+ fill="none"
32
+ stroke="currentColor"
33
+ stroke-width="2"
34
+ stroke-linecap="round"
35
+ stroke-linejoin="round"
36
+ class="btn-icon"
37
+ ><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg
38
+ >
39
+ </a>
40
+ <a
41
+ href="https://github.com/brightside-ou/kama"
42
+ target="_blank"
43
+ rel="noopener noreferrer"
44
+ class="btn btn-secondary"
45
+ >
46
+ View on GitHub
47
+ </a>
48
+ </div>
49
+
50
+ <!-- Tech Stack Highlights -->
51
+ <div class="hero-stack">
52
+ <span class="stack-label">Powered by edge infrastructure:</span>
53
+ <div class="stack-tags">
54
+ <span class="tag">Cloudflare Workers</span>
55
+ <span class="tag">D1 SQL</span>
56
+ <span class="tag">R2 Storage</span>
57
+ <span class="tag">Astro</span>
58
+ <span class="tag">Hono</span>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ </section>
63
+
64
+ <style>
65
+ .hero-container {
66
+ padding: 64px 0 48px;
67
+ background: linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
68
+ border-bottom: 1px solid #e2e8f0;
69
+ }
70
+
71
+ .hero-content {
72
+ max-width: 860px;
73
+ margin: 0 auto;
74
+ text-align: center;
75
+ }
76
+
77
+ .hero-badge {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 8px;
81
+ padding: 6px 14px;
82
+ background-color: #f0f9ff;
83
+ border: 1px solid #bae6fd;
84
+ color: #0369a1;
85
+ font-size: 0.875rem;
86
+ font-weight: 600;
87
+ margin-bottom: 24px;
88
+ }
89
+
90
+ .badge-dot {
91
+ width: 8px;
92
+ height: 8px;
93
+ background-color: #0284c7;
94
+ border-radius: 50% !important;
95
+ }
96
+
97
+ .hero-title {
98
+ font-size: 3rem;
99
+ font-weight: 800;
100
+ line-height: 1.15;
101
+ color: #0f172a;
102
+ letter-spacing: -0.03em;
103
+ margin-top: 0;
104
+ margin-bottom: 20px;
105
+ }
106
+
107
+ .hero-subtitle {
108
+ font-size: 1.25rem;
109
+ line-height: 1.6;
110
+ color: #475569;
111
+ max-width: 720px;
112
+ margin: 0 auto 36px;
113
+ }
114
+
115
+ .hero-actions {
116
+ display: flex;
117
+ align-items: center;
118
+ justify-content: center;
119
+ gap: 16px;
120
+ margin-bottom: 48px;
121
+ }
122
+
123
+ .btn {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ justify-content: center;
127
+ gap: 8px;
128
+ padding: 12px 28px;
129
+ font-size: 1rem;
130
+ font-weight: 600;
131
+ text-decoration: none;
132
+ transition: all 0.15s ease-in-out;
133
+ }
134
+
135
+ .btn-primary {
136
+ background-color: #0284c7;
137
+ color: #ffffff;
138
+ }
139
+
140
+ .btn-primary:hover {
141
+ background-color: #0369a1;
142
+ color: #ffffff;
143
+ }
144
+
145
+ .btn-secondary {
146
+ background-color: #ffffff;
147
+ color: #334155;
148
+ border: 1px solid #cbd5e1;
149
+ }
150
+
151
+ .btn-secondary:hover {
152
+ background-color: #f8fafc;
153
+ color: #0f172a;
154
+ border-color: #94a3b8;
155
+ }
156
+
157
+ .btn-icon {
158
+ transition: transform 0.15s ease;
159
+ }
160
+
161
+ .btn-primary:hover .btn-icon {
162
+ transform: translateX(4px);
163
+ }
164
+
165
+ .hero-stack {
166
+ display: flex;
167
+ flex-direction: column;
168
+ align-items: center;
169
+ gap: 12px;
170
+ }
171
+
172
+ .stack-label {
173
+ font-size: 0.8125rem;
174
+ text-transform: uppercase;
175
+ letter-spacing: 0.05em;
176
+ color: #64748b;
177
+ font-weight: 600;
178
+ }
179
+
180
+ .stack-tags {
181
+ display: flex;
182
+ flex-wrap: wrap;
183
+ justify-content: center;
184
+ gap: 8px;
185
+ }
186
+
187
+ .tag {
188
+ padding: 4px 10px;
189
+ background-color: #f1f5f9;
190
+ color: #334155;
191
+ font-size: 0.8125rem;
192
+ font-weight: 500;
193
+ border: 1px solid #e2e8f0;
194
+ }
195
+
196
+ @media (max-width: 768px) {
197
+ .hero-container {
198
+ padding: 40px 0 32px;
199
+ }
200
+
201
+ .hero-title {
202
+ font-size: 2.125rem;
203
+ }
204
+
205
+ .hero-subtitle {
206
+ font-size: 1.05rem;
207
+ }
208
+
209
+ .hero-actions {
210
+ flex-direction: column;
211
+ width: 100%;
212
+ }
213
+
214
+ .btn {
215
+ width: 100%;
216
+ }
217
+ }
218
+ </style>