@codecavepro/brand 2.0.0 → 2.1.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.
package/README.md CHANGED
@@ -149,18 +149,22 @@ Two things to know about it:
149
149
 
150
150
  ## The components
151
151
 
152
- 13 components and 13 icons — the buttons, the form controls and the forms
153
- themselves, the review card and the typing effect codecave.pro renders ship as
152
+ 19 components and 13 icons — the buttons, the form controls and the forms
153
+ themselves, the nav bar, the footer link group, the article and technology
154
+ cards, the review card and the typing effect codecave.pro renders — ship as
154
155
  **source**, byte-for-byte the files the site builds from. Not a reimplementation
155
156
  of them, and not a bundle compiled from a snapshot: the same files, kept
156
157
  identical by a check that runs on every push.
157
158
 
158
- **The site's own menus are deliberately absent, and `BrandNav` is why they can
159
- be.** The footer link group, the article and technology cards and the two menus
160
- all reach codecave.pro's route table and menu data, so shipping them would put
161
- one site's navigation behind an npm release changing a menu item would mean
162
- publishing. `BrandNav` is the same bar with the coupling removed: it takes its
163
- items, its wordmark and its active state as props and reaches nothing.
159
+ **None of them knows your routes.** Six of these shipped for the first time in
160
+ 2.1.0 and were held back before it, because each read codecave.pro's own route
161
+ table or menu data directly installing one would have put that site's
162
+ navigation behind an npm release, so changing a menu item would mean publishing.
163
+ They now take the same information as props: `basePath` on `ArticlePreview`,
164
+ `items` on `link-group` and `services-list`, `serviceLinks` and `ctaHref` on
165
+ `technologies`, `href` on `technology-card`, `items` and `logo` on
166
+ `mobile-menu`. `BrandNav` was the first to be built that way and is the reason
167
+ the rest could follow.
164
168
 
165
169
  ```vue
166
170
  <BrandNav
@@ -0,0 +1,61 @@
1
+ <script setup lang="ts">
2
+ import { formattedDate } from "../../helpers/date-formatter.ts";
3
+
4
+ /* The fields this component actually reads, instead of the generated Strapi
5
+ * `Article`. TypeScript is structural, so a real Article still satisfies this
6
+ * -- callers pass exactly what they passed before -- but the component no
7
+ * longer carries the CMS schema with it. */
8
+ type ArticleSummary = {
9
+ slug: string | null
10
+ title: string
11
+ excerpt: string
12
+ date: Date | null
13
+ locale: string | null
14
+ readingtime: unknown
15
+ cover: { url: string; name: string }
16
+ }
17
+
18
+ const props = defineProps<{
19
+ article: ArticleSummary
20
+ className?: string
21
+ /* CMS media URLs arrive relative ("uploads/x.png"). The site injects its own
22
+ * resolver; a caller whose URLs are already absolute passes nothing. */
23
+ resolveImage?: (url: string) => string
24
+ /* Prefix the slug is appended to. Was `paths.insights`, read straight off
25
+ * this site's route table -- the last thing keeping this component from
26
+ * being installable. The caller owns where its articles live. */
27
+ basePath?: string
28
+ }>()
29
+
30
+ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
31
+ </script>
32
+
33
+ <template>
34
+ <a :href="`${basePath ?? ''}${article.slug}/`" :class="`mx-1 lg:mx-2 w-full h-full self-start sm:self-auto p-6 flex flex-col gap-5 sm:gap-8
35
+ rounded-[2.25rem] bg-surface-secondary hover:bg-surface-secondary transition-colors cursor-pointer border-surface-tertiary border
36
+ ${className ?? ''}`">
37
+ <div class="flex flex-col sm:flex-row gap-5 sm:gap-8 h-fit">
38
+ <img loading="lazy" class="sm:w-[132px] sm:h-[132px] rounded-xl object-cover"
39
+ :src="imageUrl(article.cover.url)"
40
+ :alt="article.cover.name"
41
+ :width="100"
42
+ :height="100"/>
43
+ <div class="space-y-2 sm:space-y-3">
44
+ <time class="text-body-secondary text-xs sm:text-sm" :datetime="article.date?.toString()">
45
+ {{ formattedDate(article.locale, article.date) }}
46
+ </time>
47
+ <h3 class="font-bold text-lg md:text-xl text-heading">
48
+ {{ article.title }}
49
+ </h3>
50
+ </div>
51
+ </div>
52
+ <div class="text-sm flex flex-col justify-between h-full">
53
+ <p class="text-body-secondary-lighter">
54
+ {{ article.excerpt }}
55
+ </p>
56
+ <p class="text-xs sm:text-sm text-body-secondary mt-6 sm:mt-7">
57
+ Reading time: {{ article.readingtime }} m.
58
+ </p>
59
+ </div>
60
+ </a>
61
+ </template>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * The CODECAVE top bar. Ghost-button links split around a centred wordmark.
3
+ * The CODECAVE top bar. Ghost-style links split around a centred wordmark.
4
4
  *
5
5
  * AUTHORED, not captured. Every other component this package ships is a copy of
6
6
  * a file codecave.pro builds from, and docs/source_examples/ holds nothing
@@ -9,8 +9,8 @@
9
9
  * into one, so neither repo owns it any more.
10
10
  *
11
11
  * WHY IT CAN SHIP WHEN THE MENUS COULD NOT. desktop-menu.vue and mobile-menu.vue
12
- * are excluded from this package (CCWEB2-371) because they reach the site paths.ts
13
- * and menu.ts -- installing one would put codecave.pro navigation behind an npm
12
+ * were excluded from this package (CCWEB2-371) because they reach the site
13
+ * menu.ts -- installing one would put codecave.pro navigation behind an npm
14
14
  * release. This takes its items, its logo and its active state as PROPS, so it
15
15
  * reaches nothing. That is the whole difference, and it is why the same bar can
16
16
  * serve a marketing site and a documentation site without either one lending the
@@ -20,6 +20,17 @@
20
20
  * HTML and ships no JavaScript, which is what the docs bar already does and what
21
21
  * the site header does NOT -- it is `client:only`, so it renders nothing at all
22
22
  * until Vue boots. The dropdown below is CSS hover for the same reason.
23
+ *
24
+ * IT NEEDS tokens.css AND NOTHING ELSE. The links are styled here rather than
25
+ * borrowed, and that is deliberate: the two ghost buttons this system already
26
+ * has DISAGREE, so there was nothing to borrow. colors_and_type.css's
27
+ * `.btn-ghost` renders 48px, because it cancels `height` while `.btn` sets
28
+ * `min-height`; Button.vue's `ghost` variant renders 40px, because it drops the
29
+ * `min-h-12` off its base class. Eight pixels, one of them a dead override of
30
+ * the kind CCWEB2-331 records elsewhere. Neither is portable either -- a docs
31
+ * page has the stylesheet and no Tailwind, a consumer site has Tailwind and not
32
+ * the stylesheet. Standing on tokens instead means this bar renders the same in
33
+ * both, and a consumer needs no global class layer to install it.
23
34
  */
24
35
  export interface NavItem {
25
36
  /** Link text, and the value `current` is matched against. */
@@ -28,6 +39,11 @@ export interface NavItem {
28
39
  href?: string;
29
40
  /** Names a slot to render as a hover dropdown under this item. */
30
41
  slot?: string;
42
+ /** Draws the attention dot. codecave.pro puts it on "Contact us"; it used to
43
+ * be a `ul:last-of-type li:last-child::after` rule, which meant the marker
44
+ * belonged to a POSITION rather than to an item and moved on its own the
45
+ * first time the menu was reordered. Naming the item is the fix. */
46
+ badge?: boolean;
31
47
  }
32
48
 
33
49
  withDefaults(defineProps<{
@@ -39,7 +55,14 @@ withDefaults(defineProps<{
39
55
  * its own asset pipeline, and this package ships no brand marks. */
40
56
  logo: string;
41
57
  logoHref?: string;
58
+ /** Alt text of the wordmark image. */
42
59
  logoAlt?: string;
60
+ /** Accessible name of the wordmark LINK, when it should differ from the alt
61
+ * text -- "CODECAVE design system, documentation home" reads better on a
62
+ * home link than the wordmark does. Left off, the link is named by the image
63
+ * alt, which is the right default and the reason this is not simply
64
+ * `logoAlt` twice. */
65
+ logoLabel?: string;
43
66
  /** `name` of the item to mark aria-current="page". */
44
67
  current?: string;
45
68
  }>(), {
@@ -54,33 +77,33 @@ withDefaults(defineProps<{
54
77
  <nav class="brand-nav" aria-label="Main">
55
78
  <div class="brand-nav-inner">
56
79
  <ul>
57
- <li v-for="item in left" :key="item.name" :class="{ dropdown: item.slot }">
80
+ <li v-for="item in left" :key="item.name" :class="{ dropdown: item.slot, badged: item.badge }">
58
81
  <a
59
82
  v-if="item.href"
60
- class="btn btn-ghost"
83
+ class="brand-nav-link"
61
84
  :href="item.href"
62
- :aria-current="item.name === current ? &apos;page&apos; : undefined"
85
+ :aria-current="item.name === current ? 'page' : undefined"
63
86
  >{{ item.name }}</a>
64
- <span v-else class="btn btn-ghost dropbtn">{{ item.name }}<slot name="chevron" /></span>
87
+ <span v-else class="brand-nav-link dropbtn">{{ item.name }}<slot name="chevron" /></span>
65
88
  <div v-if="item.slot" class="dropdown-content">
66
89
  <slot :name="item.slot" />
67
90
  </div>
68
91
  </li>
69
92
  </ul>
70
93
 
71
- <a class="brand-nav-logo" :href="logoHref" :aria-label="logoAlt">
94
+ <a class="brand-nav-logo" :href="logoHref" :aria-label="logoLabel">
72
95
  <img :src="logo" :alt="logoAlt" />
73
96
  </a>
74
97
 
75
98
  <ul>
76
- <li v-for="item in right" :key="item.name" :class="{ dropdown: item.slot }">
99
+ <li v-for="item in right" :key="item.name" :class="{ dropdown: item.slot, badged: item.badge }">
77
100
  <a
78
101
  v-if="item.href"
79
- class="btn btn-ghost"
102
+ class="brand-nav-link"
80
103
  :href="item.href"
81
- :aria-current="item.name === current ? &apos;page&apos; : undefined"
104
+ :aria-current="item.name === current ? 'page' : undefined"
82
105
  >{{ item.name }}</a>
83
- <span v-else class="btn btn-ghost dropbtn">{{ item.name }}<slot name="chevron" /></span>
106
+ <span v-else class="brand-nav-link dropbtn">{{ item.name }}<slot name="chevron" /></span>
84
107
  <div v-if="item.slot" class="dropdown-content">
85
108
  <slot :name="item.slot" />
86
109
  </div>
@@ -91,16 +114,11 @@ withDefaults(defineProps<{
91
114
  </template>
92
115
 
93
116
  <style scoped>
94
- /* Layout only. Every colour comes from colors_and_type.css, which ships beside
95
- * this file -- .btn-ghost included, so the links are the same ghost buttons the
96
- * rest of the system uses and a palette change moves the bar with it.
97
- *
98
- * The three values that used to differ between the site bar and the docs bar are
99
- * settled here on the docs side: 16px links on the 48px --control-height grid,
100
- * a 28px wordmark, and the 1px rule underneath. The site had shrunk all three
101
- * with a text-sm wrapper, which quietly took its ghost buttons off the control
102
- * grid every other control sits on. */
117
+ /* Every colour and nearly every length below is a token from tokens.css. The
118
+ * literals that remain are the link padding and the blur radius, neither of
119
+ * which the token set names. */
103
120
  .brand-nav {
121
+ --brand-nav-pad: 0.75rem; /* the site header's py-3 */
104
122
  position: sticky;
105
123
  top: 0;
106
124
  z-index: 100;
@@ -111,14 +129,28 @@ withDefaults(defineProps<{
111
129
  }
112
130
 
113
131
  .brand-nav-inner {
114
- box-sizing: border-box;
132
+ box-sizing: border-box; /* the site resets this globally; a docs page does not */
115
133
  position: relative;
116
134
  display: flex;
117
135
  align-items: center;
118
136
  justify-content: space-between;
119
137
  max-width: var(--max-width-desktop);
120
138
  margin: 0 auto;
121
- padding: 0.75rem 1.25rem;
139
+ /* Pins the row, so the bar's height follows --control-height rather than
140
+ whatever the links happen to measure. */
141
+ min-height: calc(var(--control-height) + 2 * var(--brand-nav-pad));
142
+ padding: var(--brand-nav-pad) var(--gutter-base);
143
+ }
144
+
145
+ /* The page gutter, stepped at the same two breakpoints .page-container steps at.
146
+ It is repeated here rather than read off --gutter-base because that token does
147
+ not change with the viewport -- the class switches to a different token
148
+ instead. A bar that skipped this sat 20px inboard of every heading it topped. */
149
+ @media (min-width: 768px) {
150
+ .brand-nav-inner { padding-left: var(--gutter-md); padding-right: var(--gutter-md); }
151
+ }
152
+ @media (min-width: 1280px) {
153
+ .brand-nav-inner { padding-left: var(--gutter-xl); padding-right: var(--gutter-xl); }
122
154
  }
123
155
 
124
156
  .brand-nav ul {
@@ -128,6 +160,47 @@ withDefaults(defineProps<{
128
160
  padding: 0;
129
161
  list-style: none;
130
162
  }
163
+ .brand-nav li { margin: 0; }
164
+
165
+ /* The ghost button, declared rather than inherited. See the note in the script
166
+ block: the two that exist disagree by 8px, and neither is available in both of
167
+ the places this bar has to render. */
168
+ .brand-nav-link {
169
+ box-sizing: border-box;
170
+ display: inline-flex;
171
+ align-items: center;
172
+ justify-content: center;
173
+ min-height: var(--control-height);
174
+ padding: 0.5rem 1.5rem;
175
+ border-radius: var(--radius-pill);
176
+ color: var(--color-body-primary);
177
+ font-family: var(--font-sans);
178
+ font-size: var(--text-body);
179
+ font-weight: var(--font-weight-bold);
180
+ line-height: var(--leading-body);
181
+ text-decoration: none;
182
+ white-space: nowrap;
183
+ cursor: pointer;
184
+ transition: color var(--transition-colors);
185
+ }
186
+ .brand-nav-link:hover,
187
+ .brand-nav-link:active { color: var(--color-hovered); }
188
+
189
+ /* The attention dot. Sized and placed against the 48px row rather than the 40px
190
+ one the site drew it on, so it stays clear of the pill's right edge. */
191
+ .badged { position: relative; }
192
+ .badged::after {
193
+ content: "";
194
+ position: absolute;
195
+ top: 15px;
196
+ right: 8px;
197
+ width: 10px;
198
+ height: 10px;
199
+ border-radius: 50%;
200
+ background: var(--color-error-400);
201
+ box-shadow: 0 0 12px 0 var(--color-error-400),
202
+ 0 0 4px 0 hsl(from var(--color-error-400) h s l / 0.5);
203
+ }
131
204
 
132
205
  .brand-nav-logo {
133
206
  position: absolute;
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import Button from "../common/Button.vue";
3
+
4
+ /* Declared here rather than imported from ./links.ts, which is this site's
5
+ * footer content. TypeScript is structural, so every existing caller still
6
+ * passes exactly what it passed before -- links.ts's own `Link` satisfies this
7
+ * -- but the component no longer reaches out of itself for a two-field shape.
8
+ * Same reasoning as ArticlePreview.vue's ArticleSummary. */
9
+ type LinkItem = {
10
+ name: string
11
+ href: string
12
+ }
13
+
14
+ defineProps<{
15
+ groupName: string
16
+ items: LinkItem[]
17
+ }>()
18
+ </script>
19
+
20
+ <template>
21
+ <div class="space-y-4">
22
+ <p class="text-body-secondary uppercase font-bold text-xs">
23
+ {{ groupName }}
24
+ </p>
25
+ <div class="space-y-3 xl:space-y-2 text-sm">
26
+ <div :key="index" v-for="(item, index) in items">
27
+ <Button
28
+ :title="item.name"
29
+ as="link"
30
+ :href="item.href"
31
+ variant="text"
32
+ />
33
+ </div>
34
+ </div>
35
+ </div>
36
+ </template>
@@ -0,0 +1,136 @@
1
+ <script setup lang="ts">
2
+ import Button from "../common/Button.vue";
3
+ import Shevron from "../../assets/icons/shevron.vue";
4
+ import { computed, onUnmounted, ref, watch } from "vue";
5
+ import BackIcon from "../../assets/icons/back-icon.vue";
6
+ import ServicesList from "./services-list.vue";
7
+
8
+ /* Everything this drawer used to read out of menu.ts, paths.ts and the site's
9
+ * logo asset now arrives as props, so it reaches no route table and no asset
10
+ * pipeline of its own.
11
+ *
12
+ * The Services branch keys off `submenu` being present rather than off the
13
+ * label being the string "Services"; mobileTitle and emphasis carry the two
14
+ * other wordings that were hard-coded here. */
15
+ const props = defineProps<{
16
+ items: {
17
+ name: string
18
+ link?: string
19
+ submenuTitle?: string
20
+ submenu?: { icon: unknown; name: string; description: string; link: string }[]
21
+ mobileTitle?: string
22
+ emphasis?: boolean
23
+ }[]
24
+ /** Resolved URL of the wordmark. */
25
+ logo: string
26
+ homeHref?: string
27
+ }>()
28
+
29
+ const servicesItem = computed(() => props.items.find((i) => i.submenu?.length))
30
+
31
+ const isMenuOpen = ref(false)
32
+ const isServicesOpen = ref(false)
33
+ let scrollPosition = 0
34
+
35
+ const lockScroll = () => {
36
+ scrollPosition = window.scrollY || window.pageYOffset
37
+
38
+ document.body.style.overflow = 'hidden'
39
+ document.body.style.position = 'fixed'
40
+ document.body.style.top = `-${scrollPosition}px`
41
+ document.body.style.width = '100%'
42
+
43
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
44
+ if (scrollbarWidth > 0) {
45
+ document.body.style.paddingRight = `${scrollbarWidth}px`
46
+ }
47
+ }
48
+
49
+ const unlockScroll = () => {
50
+ document.body.style.position = ''
51
+ document.body.style.top = ''
52
+ document.body.style.width = ''
53
+ document.body.style.paddingRight = ''
54
+ document.body.style.overflow = ''
55
+ window.scrollTo(0, scrollPosition)
56
+ }
57
+
58
+ watch(isMenuOpen, (newVal) => {
59
+ if (newVal) {
60
+ lockScroll()
61
+ } else {
62
+ unlockScroll()
63
+ }
64
+ })
65
+
66
+ onUnmounted(() => {
67
+ if (isMenuOpen.value) {
68
+ unlockScroll()
69
+ }
70
+ })
71
+ const handleCloseMenu = () => {
72
+ isMenuOpen.value = false
73
+ }
74
+ </script>
75
+
76
+ <template>
77
+ <div>
78
+ <div :class="`fixed z-50 top-0 w-full bg-surface-primary-transparent backdrop-blur-3xl ${isMenuOpen ? 'rounded-b-3xl' : 'rounded-none'}`">
79
+ <div :class="`h-full px-5 ${isMenuOpen ? 'pb-5' : 'pb-0'} flex flex-col gap-2 transition-all`">
80
+ <div class="flex justify-between items-center py-1.5">
81
+ <a :href="homeHref" class="px-1.5 hover:opacity-80 transition-opacity">
82
+ <img :src="logo" alt="CODECAVE" />
83
+ </a>
84
+ <button @click="isMenuOpen = !isMenuOpen" :class="`burger-menu ${isMenuOpen ? 'text-action' : 'text-heading'} transition-colors`">
85
+ <span></span>
86
+ </button>
87
+ </div>
88
+ <nav v-if="isMenuOpen" class="h-full text-sm">
89
+ <div v-if="isServicesOpen">
90
+ <h2 class="text-center font-bold text-body-primary py-2.5">
91
+ {{ servicesItem?.submenuTitle }}
92
+ </h2>
93
+ <ServicesList :items="servicesItem?.submenu ?? []" />
94
+ <button class="pt-4" @click="isServicesOpen = false">
95
+ <component class="w-11 h-11 text-action" :is="BackIcon" />
96
+ </button>
97
+ </div>
98
+ <ul v-else class="h-full flex flex-col items-center gap-3 justify-around px-4 pt-2">
99
+ <li v-for="(item, index) in items" :key="index">
100
+ <div v-if="item.submenu?.length">
101
+ <Button :title="item.name" variant="ghost" @click="isServicesOpen = true" class="transition-transform duration-150 pr-1">
102
+ <Shevron class="rotate-270 ml-1" />
103
+ </Button>
104
+ </div>
105
+ <Button v-else-if="item.emphasis" as="link" :href="item.link" :title="item.mobileTitle ?? item.name" variant="tertiary" @click="handleCloseMenu" />
106
+ <Button v-else as="link" variant="ghost" :title="item.mobileTitle ?? item.name" :href="item.link" />
107
+ </li>
108
+ </ul>
109
+ </nav>
110
+ </div>
111
+ </div>
112
+ <div v-if="isMenuOpen" class="fixed z-0 inset-0" @click="handleCloseMenu">
113
+ </div>
114
+ </div>
115
+ </template>
116
+
117
+ <style scoped>
118
+ .burger-menu {
119
+ display: flex;
120
+ justify-content: space-around;
121
+ flex-direction: column;
122
+ padding: 0.625rem;
123
+ width: 3rem;
124
+ height: 3rem;
125
+ }
126
+
127
+ .burger-menu::before,
128
+ .burger-menu::after,
129
+ .burger-menu span {
130
+ content: '';
131
+ width: 100%;
132
+ height: 0.1rem;
133
+ background-color: currentColor;
134
+ border-radius: 3rem;
135
+ }
136
+ </style>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ /* The submenu entries to render. This read menu.ts directly, which is this
3
+ * site's navigation content -- the one thing keeping the component out of the
4
+ * brand package. The caller passes the list instead. */
5
+ defineProps<{
6
+ items: {
7
+ icon: unknown
8
+ name: string
9
+ description: string
10
+ link: string
11
+ }[]
12
+ }>()
13
+ </script>
14
+
15
+ <template>
16
+ <ul class="grid xl:grid-cols-2 gap-3.5 xl:gap-x-8 xl:gap-y-4">
17
+ <li v-for="item in items" :key="item.name">
18
+ <a :href="item.link" class="flex items-center gap-3 group">
19
+ <div class="w-fit p-3 rounded-xl bg-surface-tertiary">
20
+ <component
21
+ class="w-5 h-5 text-default-transparent group-active:text-hovered group-hover:text-hovered transition-colors"
22
+ :is="item.icon"
23
+ />
24
+ </div>
25
+ <div class="text-sm">
26
+ <h3 class="font-bold text-heading group-active:text-hovered group-hover:text-hovered transition-colors">
27
+ {{ item.name }}
28
+ </h3>
29
+ <p class="text-body-secondary-lighter group-active:text-hovered group-hover:text-hovered transition-colors">
30
+ {{ item.description }}
31
+ </p>
32
+ </div>
33
+ </a>
34
+ </li>
35
+ </ul>
36
+ </template>
@@ -0,0 +1,135 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { Carousel, type CarouselConfig, Slide } from "vue3-carousel";
4
+ import "vue3-carousel/carousel.css";
5
+ import { BREAKPOINTS } from "../../helpers/breakpoints.ts";
6
+ import GlowButton from "../common/GlowButton.vue";
7
+ import TypingEffect from "../common/effects/TypingEffect.vue";
8
+ import TechnologyCard from "./technology-card.vue";
9
+ import { carouselGestureLocker } from "../../helpers/carousel-gesture-locker.ts";
10
+
11
+ /* See ArticlePreview.vue: the fields read here, not the generated Strapi
12
+ * `Technology`, which a caller may still pass unchanged. This component
13
+ * renders no images, so it needs no resolver. */
14
+ type TechnologySummary = {
15
+ name: string
16
+ title1: string
17
+ title2: string
18
+ }
19
+
20
+ const props = defineProps<{
21
+ technologies: TechnologySummary[]
22
+ hideSelected?: boolean
23
+ /* Where the consultation button goes. Was `paths.contactUs`, read straight
24
+ * off this site's route table. */
25
+ ctaHref?: string
26
+ /* Technology name to service URL. Plain data rather than a resolver function
27
+ * because every call site mounts this with `client:only`, and Astro can only
28
+ * hand an island props it can serialise. @helpers/service-links.ts holds the
29
+ * site's map; a name that is missing renders an inert button. */
30
+ serviceLinks?: Record<string, string>
31
+ }>()
32
+
33
+ const activeIndex = ref(0)
34
+ const defaultAutoplayValue = 12000
35
+ const autoplay = ref(defaultAutoplayValue)
36
+ const handleMouseOver = (index: number) => {
37
+ activeIndex.value = index
38
+ autoplay.value = 0
39
+ }
40
+ const selectedTechnology = computed(() => {
41
+ return props.technologies[activeIndex.value]
42
+ })
43
+ const handleMouseLeave = () => {
44
+ activeIndex.value = 0
45
+ autoplay.value = defaultAutoplayValue
46
+ }
47
+ const config = computed<Partial<CarouselConfig>>(() => ({
48
+ gap: 8,
49
+ itemsToShow: 'auto',
50
+ autoplay: autoplay.value,
51
+ wrapAround: true,
52
+ height: 220,
53
+ snapAlign: 'center',
54
+ pauseAutoplayOnHover: true,
55
+ transition: 500,
56
+ breakpoints: {
57
+ [BREAKPOINTS.md]: {
58
+ gap: 16,
59
+ },
60
+ [BREAKPOINTS.xl]: {
61
+ enabled: false,
62
+ },
63
+ }
64
+ }))
65
+ const carouselLocker = carouselGestureLocker()
66
+ </script>
67
+
68
+ <template>
69
+ <section :class="`page-container
70
+ ${hideSelected ? 'pt-8' : 'flex flex-col pt-4 md:pt-14 pb-20 xl:pb-0 swipe-over'}`">
71
+ <div v-if="!hideSelected" class="flex flex-col items-center text-center">
72
+ <div class="space-y-3">
73
+ <div class="flex justify-center gap-6 font-bold text-xl">
74
+ <div class="flex flex-col xl:flex-row xl:gap-2">
75
+ <span class="text-body-primary">15+</span>
76
+ <span class="text-body-secondary-lighter">Years of experience</span>
77
+ </div>
78
+ <div class="flex flex-col xl:flex-row xl:gap-2">
79
+ <span class="text-body-primary">4.8</span>
80
+ <span class="text-body-secondary-lighter">Average rating</span>
81
+ </div>
82
+ </div>
83
+ <TypingEffect
84
+ :text1="selectedTechnology.title1"
85
+ :text2="selectedTechnology.title2"
86
+ />
87
+ </div>
88
+ </div>
89
+ <div v-if="!hideSelected" class="self-center pt-10 pb-[4.5rem] md:pt-14 md:pb-16">
90
+ <GlowButton title="Get a free consultation" :href="ctaHref" />
91
+ </div>
92
+ <div v-else class="pt-[7.5rem] pb-20 space-y-7 text-center">
93
+ <h2 class="font-bold text-heading-md text-heading">
94
+ Looking for something specific?
95
+ </h2>
96
+ <p class="text-xl mx-auto max-w-lg text-body-secondary-lighter">
97
+ We offer a variety of services that might be a game-changer for your business.
98
+ </p>
99
+ </div>
100
+ <div class="hidden xl:block relative h-80">
101
+ <div
102
+ v-for="(item, index) in technologies"
103
+ :key="index"
104
+ @mouseover="handleMouseOver(index)"
105
+ @mouseleave="handleMouseLeave"
106
+ >
107
+ <TechnologyCard
108
+ :name="item.name"
109
+ :href="serviceLinks?.[item.name]"
110
+ :active="activeIndex === index"
111
+ className="w-72 h-72"
112
+ :index="index"
113
+ />
114
+ </div>
115
+ </div>
116
+ <div ref="carouselLocker">
117
+ <Carousel
118
+ v-model="activeIndex"
119
+ v-bind="config"
120
+ class="-mx-4 xl:mx-0 xl:hidden"
121
+ >
122
+ <Slide v-for="(item, index) in technologies" :key="index">
123
+ <div class="flex items-center w-[190px] h-[220px]">
124
+ <TechnologyCard
125
+ :name="item.name"
126
+ :href="serviceLinks?.[item.name]"
127
+ :active="activeIndex === index"
128
+ :className="`w-full h-full ${activeIndex === index ? 'max-h-full' : 'max-h-[190px]'}`"
129
+ />
130
+ </div>
131
+ </Slide>
132
+ </Carousel>
133
+ </div>
134
+ </section>
135
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import Button from "../common/Button.vue";
3
+
4
+ defineProps<{
5
+ active: boolean
6
+ name: string
7
+ /* Where "Explore service" goes. The card used to switch on `name` over this
8
+ * site's six service routes; that map is now @helpers/service-links.ts and
9
+ * the caller passes the result, so the card reaches no route table. An empty
10
+ * string renders the button inert, exactly as the switch's default did. */
11
+ href?: string
12
+ className?: string
13
+ index?: number
14
+ }>()
15
+
16
+ const rotate = [
17
+ '-rotate-4 left-2 top-10',
18
+ '-rotate-1 left-1/4 top-20',
19
+ 'rotate-1 left-1/2 top-20',
20
+ 'rotate-4 right-2 top-10',
21
+ '-rotate-2 right-2/3 top-3/5',
22
+ 'rotate-2 left-2/3 top-3/5',
23
+ ]
24
+
25
+ const translate = [
26
+ 'xl:-translate-y-1/3',
27
+ 'xl:-translate-y-1/3',
28
+ 'xl:-translate-y-1/3',
29
+ 'xl:-translate-y-1/3',
30
+ 'xl:-translate-y-1/2',
31
+ 'xl:-translate-y-1/2',
32
+ ]
33
+ </script>
34
+
35
+ <template>
36
+ <div :class="`rounded-3xl card-wrapper cursor-pointer select-none absolute transform transition-transform duration-500
37
+ ${rotate[index]} ${active ? `${translate[index]}` : ''} ${className || ''}`">
38
+ <div class="card flex flex-col items-center justify-around">
39
+ <h3 class="max-w-[8rem] text-center text-xl font-bold text-heading text-balance">
40
+ {{ name }}
41
+ </h3>
42
+ <Button as="link" :href="href ?? ''" title="Explore service" variant="tertiary" :class="`${active ? 'block' : 'hidden xl:block xl:opacity-0'}`" />
43
+ </div>
44
+ </div>
45
+ </template>
46
+
47
+ <style scoped>
48
+ .card-wrapper {
49
+ background:
50
+ linear-gradient(hsl(from var(--color-technology-gradient-25) h s l / 0.1),
51
+ hsl(from var(--color-technology-gradient-0) h s l / 0.1),
52
+ hsl(from var(--color-technology-gradient-50) h s l / 0.1)) border-box;
53
+ }
54
+
55
+ .card {
56
+ width: 100%;
57
+ height: 100%;
58
+ border-radius: inherit;
59
+ position: relative;
60
+ background: transparent;
61
+ backdrop-filter: blur(14px);
62
+ }
63
+
64
+ .card::before {
65
+ content: '';
66
+ position: absolute;
67
+ z-index: -1;
68
+ inset: 0;
69
+ border-radius: inherit;
70
+ border: 1px solid transparent;
71
+ outline: 1px solid transparent;
72
+ -webkit-backface-visibility: hidden;
73
+ transform: translate3d(0, 0, 0);
74
+ background: linear-gradient(hsl(from var(--color-brand-500) h s l / 0.35),
75
+ hsl(from var(--color-brand-400) h s l / 0.675),
76
+ var(--color-brand-500)) border-box;
77
+ mask: linear-gradient(black, black) border-box,
78
+ linear-gradient(black, black) padding-box;
79
+ mask-composite: subtract;
80
+ }
81
+ </style>
@@ -0,0 +1,5 @@
1
+ export const BREAKPOINTS = {
2
+ md: 768,
3
+ lg: 1024,
4
+ xl: 1280,
5
+ }
@@ -0,0 +1,72 @@
1
+ import { onMounted, onUnmounted, ref } from 'vue';
2
+
3
+ export function carouselGestureLocker() {
4
+ const carouselRoot = ref<HTMLElement | null>(null)
5
+
6
+ let viewport: HTMLElement | null = null
7
+
8
+ let startX = 0
9
+ let startY = 0
10
+ let gestureLocked = false
11
+ let isVerticalGesture = false
12
+
13
+ const onTouchStart = (e: TouchEvent) => {
14
+ startX = e.touches[0].clientX
15
+ startY = e.touches[0].clientY
16
+
17
+ gestureLocked = false
18
+ isVerticalGesture = false
19
+ }
20
+
21
+ const onTouchMove = (e: TouchEvent) => {
22
+ const dx = Math.abs(e.touches[0].clientX - startX)
23
+ const dy = Math.abs(e.touches[0].clientY - startY)
24
+
25
+ if (!gestureLocked) {
26
+ if (dx < 10 && dy < 10) return
27
+
28
+ gestureLocked = true
29
+ isVerticalGesture = dy > dx
30
+ }
31
+
32
+ if (isVerticalGesture) {
33
+ e.stopImmediatePropagation()
34
+ }
35
+ }
36
+
37
+ const onTouchEnd = () => {
38
+ gestureLocked = false
39
+ isVerticalGesture = false
40
+ }
41
+
42
+ onMounted(() => {
43
+
44
+ viewport =
45
+ carouselRoot.value?.querySelector('.carousel__viewport') ?? null
46
+
47
+ if (!viewport) return
48
+
49
+ viewport.addEventListener('touchstart', onTouchStart, {
50
+ passive: true,
51
+ })
52
+
53
+ viewport.addEventListener('touchmove', onTouchMove, {
54
+ passive: false,
55
+ })
56
+
57
+ viewport.addEventListener('touchend', onTouchEnd)
58
+
59
+ viewport.addEventListener('touchcancel', onTouchEnd)
60
+ })
61
+
62
+ onUnmounted(() => {
63
+ if (!viewport) return
64
+
65
+ viewport.removeEventListener('touchstart', onTouchStart)
66
+ viewport.removeEventListener('touchmove', onTouchMove)
67
+ viewport.removeEventListener('touchend', onTouchEnd)
68
+ viewport.removeEventListener('touchcancel', onTouchEnd)
69
+ })
70
+
71
+ return carouselRoot;
72
+ }
@@ -0,0 +1,10 @@
1
+ export const formattedDate = (locale: string | null, date: Date | null) => {
2
+ if (!locale || !date) return '';
3
+
4
+ const newDate = new Date(date)
5
+ return new Intl.DateTimeFormat(locale, {
6
+ year: 'numeric',
7
+ month: 'long',
8
+ day: 'numeric'
9
+ }).format(newDate)
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codecavepro/brand",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "CODECAVE design system — colour, typography and layout tokens as CSS custom properties and as a typed module.",
5
5
  "license": "Unlicense",
6
6
  "type": "module",
@@ -41,7 +41,8 @@
41
41
  "gsap": "^3.13.0",
42
42
  "isomorphic-dompurify": "^3.22.0",
43
43
  "marked": "^18.0.5",
44
- "vue": "^3.5.22"
44
+ "vue": "^3.5.22",
45
+ "vue3-carousel": "^0.17.0"
45
46
  },
46
47
  "peerDependenciesMeta": {
47
48
  "gsap": {
@@ -52,6 +53,9 @@
52
53
  },
53
54
  "marked": {
54
55
  "optional": true
56
+ },
57
+ "vue3-carousel": {
58
+ "optional": true
55
59
  }
56
60
  },
57
61
  "scripts": {