@codecavepro/brand 3.2.7 → 3.4.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 +20 -11
- package/dist/colors_and_type.css +42 -6
- package/dist/fonts.css +20 -4
- package/dist/src/common/ArticlePreview.vue +3 -1
- package/dist/src/common/BrandNav.vue +5 -1
- package/dist/src/common/GlowButton.vue +2 -2
- package/dist/src/common/InputText.vue +37 -23
- package/dist/src/common/Review.vue +3 -0
- package/dist/src/common/SubNavbar.vue +3 -1
- package/dist/src/common/forms/ContactUsForm.vue +20 -11
- package/dist/src/common/technology-card.vue +5 -2
- package/dist/src/footer/link-group.vue +9 -2
- package/dist/src/header/mobile-menu.vue +5 -2
- package/dist/src/lib/i18n/messages.ts +129 -0
- package/dist/theme.css +1 -1
- package/dist/tokens/typography.d.ts +3 -2
- package/dist/tokens/typography.d.ts.map +1 -1
- package/dist/tokens/typography.js +3 -2
- package/dist/tokens.css +5 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ redesign turns into a find-and-replace across your codebase.
|
|
|
45
45
|
|
|
46
46
|
### Already have your own base styles? Import the values only
|
|
47
47
|
|
|
48
|
-
`@codecavepro/brand/css` is the design system **whole**: the tokens,
|
|
48
|
+
`@codecavepro/brand/css` is the design system **whole**: the tokens, eleven `@font-face`
|
|
49
49
|
rules, base rules for `html`, `body`, `h1`–`h6` and `a`, two layout primitives and
|
|
50
50
|
around sixty component classes. That is what you want for a page that should look like
|
|
51
51
|
CODECAVE. It is _not_ what you want in an app that already has a base layer — dropping
|
|
@@ -60,8 +60,8 @@ else:
|
|
|
60
60
|
|
|
61
61
|
Same 103 properties on `:root`, same values, zero rules. Nothing it declares can change
|
|
62
62
|
how a single existing element renders — a `var()` only takes effect where you write
|
|
63
|
-
one. Fonts come with it only as a _name_: `--font-sans` says Satoshi,
|
|
64
|
-
faces stays your call (see below).
|
|
63
|
+
one. Fonts come with it only as a _name_: `--font-sans` says Satoshi, then Manrope for
|
|
64
|
+
the Cyrillic Satoshi lacks, and declaring the faces stays your call (see below).
|
|
65
65
|
|
|
66
66
|
Use it to end a duplicated palette without signing up for a redesign in the same commit.
|
|
67
67
|
|
|
@@ -78,8 +78,8 @@ file at that URL are provably the same bytes.
|
|
|
78
78
|
|
|
79
79
|
## Fonts: this package ships none
|
|
80
80
|
|
|
81
|
-
`@codecavepro/brand/css` declares
|
|
82
|
-
binaries are included** — that is a licensing question, not an oversight. Until you
|
|
81
|
+
`@codecavepro/brand/css` declares eleven `@font-face` rules, ten for **Satoshi** and one
|
|
82
|
+
for **Manrope**, but **no font binaries are included** — that is a licensing question, not an oversight. Until you
|
|
83
83
|
supply the files, the faces 404 and the browser falls back down the stack
|
|
84
84
|
(`-apple-system`, `Segoe UI`, Roboto, …). Tokens, colours and the type _scale_ are all
|
|
85
85
|
correct regardless; only the typeface is missing.
|
|
@@ -87,14 +87,15 @@ correct regardless; only the typeface is missing.
|
|
|
87
87
|
The two stylesheets expect the files in **different places**, because one is meant to be
|
|
88
88
|
dropped into a project on its own:
|
|
89
89
|
|
|
90
|
-
| Import | `@font-face` URLs
|
|
91
|
-
| ------------------------------- |
|
|
92
|
-
| `@codecavepro/brand/css` | `./fonts/Satoshi-*.woff2`
|
|
93
|
-
| `@codecavepro/brand/fonts.css` | `./Satoshi-*.woff2`
|
|
94
|
-
| `@codecavepro/brand/tokens.css` | _none — it declares no faces_
|
|
90
|
+
| Import | `@font-face` URLs | Put the `.woff2`/`.woff` files at |
|
|
91
|
+
| ------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
|
|
92
|
+
| `@codecavepro/brand/css` | `./fonts/Satoshi-*.woff2`, `./fonts/Manrope-Cyrillic.woff2` | a `fonts/` directory **beside** the stylesheet |
|
|
93
|
+
| `@codecavepro/brand/fonts.css` | `./Satoshi-*.woff2`, `./Manrope-Cyrillic.woff2` | **beside** the stylesheet itself |
|
|
94
|
+
| `@codecavepro/brand/tokens.css` | _none — it declares no faces_ | wherever your own `@font-face` rules point |
|
|
95
95
|
|
|
96
96
|
Get the cuts from [Fontshare](https://www.fontshare.com/fonts/satoshi) — 300, 400, 500,
|
|
97
|
-
700 and 900, each upright and italic, which is the ten faces these stylesheets
|
|
97
|
+
700 and 900, each upright and italic, which is the ten Satoshi faces these stylesheets
|
|
98
|
+
declare.
|
|
98
99
|
Bind each with a real `font-weight` descriptor rather than letting the browser
|
|
99
100
|
synthesize; the design system documents why in
|
|
100
101
|
[DESIGN.md §10.3](https://github.com/CodeCavePro/brand/blob/development/DESIGN.md#103-synthesized-vs-real-font-weights).
|
|
@@ -105,6 +106,14 @@ collide into two slots and whichever is declared last silently wins — your `90
|
|
|
105
106
|
renders Bold, or your `700` renders Black, with nothing to indicate which. Take the
|
|
106
107
|
binaries from that download and the declarations from here.
|
|
107
108
|
|
|
109
|
+
**Satoshi has no Cyrillic, so Manrope sets it.** `--font-sans` names Manrope second, and
|
|
110
|
+
its one face covers only the Cyrillic block, so Satoshi keeps every Latin letter, digit
|
|
111
|
+
and punctuation mark, and a page with no Cyrillic never downloads the file. It is Google
|
|
112
|
+
Fonts' Cyrillic subset of the variable font, weights 300 to 800, in
|
|
113
|
+
[`src/styles/fonts/`](https://github.com/CodeCavePro/brand/tree/development/src/styles/fonts)
|
|
114
|
+
as `Manrope-Cyrillic.woff2`. Manrope is under the SIL Open Font License, so ship
|
|
115
|
+
`Manrope-OFL.txt` from the same folder beside it.
|
|
116
|
+
|
|
108
117
|
## The typed module
|
|
109
118
|
|
|
110
119
|
For consumers that cannot read a stylesheet — design tooling, canvas/WebGL renderers,
|
package/dist/colors_and_type.css
CHANGED
|
@@ -19,11 +19,12 @@
|
|
|
19
19
|
/* ---------------------------------------------------------------------------
|
|
20
20
|
* Font faces
|
|
21
21
|
*
|
|
22
|
-
* The ten faces are bound to explicit numeric weights in both styles.
|
|
23
|
-
* vendor's own stylesheet.css declares Bold and Black as the same
|
|
22
|
+
* The ten Satoshi faces are bound to explicit numeric weights in both styles.
|
|
23
|
+
* The vendor's own stylesheet.css declares Bold and Black as the same
|
|
24
24
|
* `font-weight: bold`, and their italics likewise, so four cuts collide into
|
|
25
25
|
* two slots there and the last one declared silently wins. That is the defect
|
|
26
26
|
* these declarations exist to avoid; do not replace them with the vendor file.
|
|
27
|
+
* An eleventh face, Manrope, follows them and sets the Cyrillic Satoshi lacks.
|
|
27
28
|
* ------------------------------------------------------------------------ */
|
|
28
29
|
|
|
29
30
|
@font-face {
|
|
@@ -116,6 +117,40 @@
|
|
|
116
117
|
font-display: swap;
|
|
117
118
|
}
|
|
118
119
|
|
|
120
|
+
/* ---------------------------------------------------------------------------
|
|
121
|
+
* Cyrillic: Manrope
|
|
122
|
+
*
|
|
123
|
+
* Satoshi has no Cyrillic, in these files or in Fontshare's current release,
|
|
124
|
+
* whose own page lists 135 languages, all written in Latin letters. Russian
|
|
125
|
+
* text used to fall through to the system fonts. Manrope sets it now:
|
|
126
|
+
* --font-sans names it second, so Satoshi keeps every Latin letter, digit and
|
|
127
|
+
* punctuation mark, and Manrope draws only the Cyrillic that Satoshi lacks.
|
|
128
|
+
*
|
|
129
|
+
* It is a family of its own, not more faces under the name Satoshi, because
|
|
130
|
+
* of emphasis. Font matching narrows a family to its italic faces for <em>
|
|
131
|
+
* before it reads unicode-range, so a Cyrillic face inside Satoshi would
|
|
132
|
+
* either never match emphasised text or match it upright. A separate family
|
|
133
|
+
* with one upright face is slanted by the browser instead.
|
|
134
|
+
*
|
|
135
|
+
* One variable file covers weights 300 to 800, and a 900 request gets 800.
|
|
136
|
+
* It is Google Fonts' Cyrillic subset under the SIL Open Font License
|
|
137
|
+
* (fonts/Manrope-OFL.txt), and the unicode-range is the subset's own, so a
|
|
138
|
+
* page without Cyrillic never downloads it.
|
|
139
|
+
*
|
|
140
|
+
* size-adjust: both faces draw capitals 72 units tall at 100px, but Manrope's
|
|
141
|
+
* small letters stand 54 to Satoshi's 49 and it sets about 8% wider. At 95%
|
|
142
|
+
* the two are within about 5% on each of those measures.
|
|
143
|
+
* ------------------------------------------------------------------------ */
|
|
144
|
+
@font-face {
|
|
145
|
+
font-family: Manrope;
|
|
146
|
+
src: url("./fonts/Manrope-Cyrillic.woff2") format("woff2");
|
|
147
|
+
font-weight: 300 800;
|
|
148
|
+
font-style: normal;
|
|
149
|
+
font-display: swap;
|
|
150
|
+
size-adjust: 95%;
|
|
151
|
+
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
152
|
+
}
|
|
153
|
+
|
|
119
154
|
:root {
|
|
120
155
|
/* =========================================================================
|
|
121
156
|
* RAW RAMPS — verbatim from global.css :root after the 2026 palette
|
|
@@ -210,11 +245,12 @@
|
|
|
210
245
|
|
|
211
246
|
/* =========================================================================
|
|
212
247
|
* TYPOGRAPHY
|
|
213
|
-
* One family
|
|
214
|
-
*
|
|
248
|
+
* One family, and Manrope for the Cyrillic it lacks (see "Cyrillic" above).
|
|
249
|
+
* Named heading sizes come from the production @theme block; the remaining
|
|
250
|
+
* steps are measured from the live site.
|
|
215
251
|
* ====================================================================== */
|
|
216
|
-
--font-sans: Satoshi, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
217
|
-
"Helvetica Neue", "Noto Sans", Arial, sans-serif;
|
|
252
|
+
--font-sans: Satoshi, Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
253
|
+
Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif;
|
|
218
254
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
219
255
|
"Liberation Mono", "Courier New", monospace;
|
|
220
256
|
|
package/dist/fonts.css
CHANGED
|
@@ -8,13 +8,19 @@
|
|
|
8
8
|
* The vendor's own stylesheet.css cannot be used: it declares Bold and Black
|
|
9
9
|
* BOTH as `font-weight: bold`, and their italics likewise, so four cuts
|
|
10
10
|
* collide into two slots and whichever is declared last silently wins. The
|
|
11
|
-
* ten faces below bind 300/400/500/700/900 in each style explicitly,
|
|
12
|
-
* the whole point of shipping real cuts rather than letting a
|
|
13
|
-
* synthesize them.
|
|
11
|
+
* ten Satoshi faces below bind 300/400/500/700/900 in each style explicitly,
|
|
12
|
+
* which is the whole point of shipping real cuts rather than letting a
|
|
13
|
+
* browser synthesize them.
|
|
14
|
+
*
|
|
15
|
+
* CYRILLIC
|
|
16
|
+
* Satoshi has none. The eleventh face, Manrope, sets it: --font-sans names it
|
|
17
|
+
* second, and its unicode-range limits it to the Cyrillic block. Why it is a
|
|
18
|
+
* family of its own and why it is 95% size is in colors_and_type.css.
|
|
14
19
|
*
|
|
15
20
|
* The binaries are not in the npm package -- see the note in
|
|
16
21
|
* packages/brand/scripts/build.mjs -- so a consumer supplies the files and
|
|
17
|
-
* these
|
|
22
|
+
* these eleven declarations tell it what to supply. Manrope-OFL.txt is the
|
|
23
|
+
* licence that travels with Manrope-Cyrillic.woff2.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
@font-face {
|
|
@@ -106,3 +112,13 @@
|
|
|
106
112
|
font-style: italic;
|
|
107
113
|
font-display: swap;
|
|
108
114
|
}
|
|
115
|
+
|
|
116
|
+
@font-face {
|
|
117
|
+
font-family: Manrope;
|
|
118
|
+
src: url("./Manrope-Cyrillic.woff2") format("woff2");
|
|
119
|
+
font-weight: 300 800;
|
|
120
|
+
font-style: normal;
|
|
121
|
+
font-display: swap;
|
|
122
|
+
size-adjust: 95%;
|
|
123
|
+
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
|
124
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { formattedDate } from "../helpers/date-formatter.ts";
|
|
3
|
+
import { formatMessage, useBrandMessages } from "../lib/i18n/messages.ts";
|
|
3
4
|
|
|
4
5
|
/* The fields this component actually reads, instead of the generated Strapi
|
|
5
6
|
* `Article`. TypeScript is structural, so a real Article still satisfies this
|
|
@@ -28,6 +29,7 @@ const props = defineProps<{
|
|
|
28
29
|
}>()
|
|
29
30
|
|
|
30
31
|
const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
32
|
+
const messages = useBrandMessages()
|
|
31
33
|
</script>
|
|
32
34
|
|
|
33
35
|
<template>
|
|
@@ -54,7 +56,7 @@ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
|
54
56
|
{{ article.excerpt }}
|
|
55
57
|
</p>
|
|
56
58
|
<p class="text-xs sm:text-sm text-body-secondary mt-6 sm:mt-7">
|
|
57
|
-
|
|
59
|
+
{{ formatMessage(messages.articlePreview.readingTime, { minutes: String(article.readingtime ?? '') }) }}
|
|
58
60
|
</p>
|
|
59
61
|
</div>
|
|
60
62
|
</a>
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
* the stylesheet. Standing on tokens instead means this bar renders the same in
|
|
33
33
|
* both, and a consumer needs no global class layer to install it.
|
|
34
34
|
*/
|
|
35
|
+
import { useBrandMessages } from '../lib/i18n/messages.ts';
|
|
36
|
+
|
|
35
37
|
export interface NavItem {
|
|
36
38
|
/** Link text, and the value `current` is matched against. */
|
|
37
39
|
name: string;
|
|
@@ -71,10 +73,12 @@ withDefaults(defineProps<{
|
|
|
71
73
|
logoHref: "/",
|
|
72
74
|
logoAlt: "CODECAVE",
|
|
73
75
|
});
|
|
76
|
+
|
|
77
|
+
const messages = useBrandMessages();
|
|
74
78
|
</script>
|
|
75
79
|
|
|
76
80
|
<template>
|
|
77
|
-
<nav class="brand-nav" aria-label="
|
|
81
|
+
<nav class="brand-nav" :aria-label="messages.brandNav.label">
|
|
78
82
|
<div class="brand-nav-inner">
|
|
79
83
|
<ul>
|
|
80
84
|
<li v-for="item in left" :key="item.name" :class="{ dropdown: item.slot, badged: item.badge }">
|
|
@@ -99,9 +99,9 @@ onMounted(() => {
|
|
|
99
99
|
@mousemove="handleMouseMove"
|
|
100
100
|
@mouseenter="handleMouseEnter"
|
|
101
101
|
@mouseleave="handleMouseLeave"
|
|
102
|
-
class="cursor-pointer flex items-center justify-center relative z-[1] overflow-hidden w-full h-12 px-6 py-1 rounded-full bg-glow-25"
|
|
102
|
+
class="cursor-pointer flex items-center justify-center relative z-[1] overflow-hidden w-full min-h-12 px-6 py-1 rounded-full bg-glow-25"
|
|
103
103
|
>
|
|
104
|
-
<p class="relative z-[2] text-primary-800 font-bold select-none">
|
|
104
|
+
<p class="relative z-[2] text-center text-primary-800 font-bold select-none">
|
|
105
105
|
{{ title }}
|
|
106
106
|
</p>
|
|
107
107
|
|
|
@@ -18,27 +18,35 @@ defineEmits(['update:modelValue'])
|
|
|
18
18
|
|
|
19
19
|
<template>
|
|
20
20
|
<div class="w-full relative">
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
<!-- The box is this wrapper rather than the input, and the label sits in
|
|
22
|
+
the flow above the input rather than floating over its top padding, as
|
|
23
|
+
TextField's does: a label that wraps, as a translated one in half a
|
|
24
|
+
form's width does, now pushes the input down instead of covering what
|
|
25
|
+
was typed. -mb-1 keeps a one-line field 66px high with its text where
|
|
26
|
+
it was. -->
|
|
27
|
+
<div :class="`input-box flex flex-col bg-surface-secondary rounded-lg border border-surface-tertiary
|
|
28
|
+
hover:bg-surface-tertiary transition-colors ${isError ? 'input-error' : ''}`">
|
|
29
|
+
<label :for="id" class="flex items-center gap-0.5 px-3 pt-3 -mb-1 font-bold text-heading text-sm">
|
|
30
|
+
<span>{{ label }}</span>
|
|
31
|
+
<AsteriskIcon v-if="isRequired" />
|
|
32
|
+
</label>
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<span v-if="isError" class="text-error text-xs">
|
|
34
|
+
<input
|
|
35
|
+
:id="id"
|
|
36
|
+
:type="type"
|
|
37
|
+
:autocomplete="autocomplete"
|
|
38
|
+
:placeholder="placeholder"
|
|
39
|
+
:required="isRequired"
|
|
40
|
+
:value="modelValue"
|
|
41
|
+
:aria-invalid="isError ? 'true' : undefined"
|
|
42
|
+
:aria-describedby="isError ? `${id}-error` : undefined"
|
|
43
|
+
:class="`
|
|
44
|
+
w-full px-3 pb-3 bg-transparent rounded-b-lg placeholder:text-xs placeholder:text-body-secondary
|
|
45
|
+
focus:outline-none
|
|
46
|
+
${isError ? 'text-error focus:text-error' : 'text-hovered focus:text-hovered'}
|
|
47
|
+
`" />
|
|
48
|
+
</div>
|
|
49
|
+
<span v-if="isError" :id="`${id}-error`" class="text-error text-xs">
|
|
42
50
|
{{ errorMessage }}
|
|
43
51
|
</span>
|
|
44
52
|
</div>
|
|
@@ -54,13 +62,19 @@ input:-webkit-autofill:active {
|
|
|
54
62
|
-webkit-background-clip: text !important;
|
|
55
63
|
}
|
|
56
64
|
|
|
57
|
-
input
|
|
65
|
+
/* The autofill colour above paints the input alone, so the label's strip of the
|
|
66
|
+
* box takes the same colour. */
|
|
67
|
+
.input-box:has(input:-webkit-autofill) {
|
|
68
|
+
background-color: var(--color-surface-tertiary);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.input-box:focus-within {
|
|
58
72
|
box-shadow: 0 0 16px 0 hsl(from var(--color-brand-500) h s l / 0.5),
|
|
59
73
|
0 0 4px 0 hsl(from var(--color-brand-500) h s l / 0.6);
|
|
60
74
|
}
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
.input-error {
|
|
76
|
+
.input-error,
|
|
77
|
+
.input-error:focus-within {
|
|
64
78
|
box-shadow: 0 0 16px 0 hsl(from var(--color-error-200) h s l / 0.5),
|
|
65
79
|
0 0 4px 0 hsl(from var(--color-error-100) h s l / 0.6);
|
|
66
80
|
}
|
|
@@ -3,6 +3,7 @@ import LinkedinIcon from "../assets/icons/linkedin-icon.vue";
|
|
|
3
3
|
import VerifiedIcon from "../assets/icons/verified-icon.vue";
|
|
4
4
|
import LazyImage from "./images/LazyImage.vue"
|
|
5
5
|
import { isCorrectLinkedInFormat } from "../helpers/form-validator.ts";
|
|
6
|
+
import { formatMessage, useBrandMessages } from "../lib/i18n/messages.ts";
|
|
6
7
|
|
|
7
8
|
/* See ArticlePreview.vue: the fields read here, not the generated Strapi
|
|
8
9
|
* `Testimonial`, which a caller may still pass unchanged. */
|
|
@@ -23,6 +24,7 @@ const props = defineProps<{
|
|
|
23
24
|
}>()
|
|
24
25
|
|
|
25
26
|
const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
27
|
+
const messages = useBrandMessages()
|
|
26
28
|
</script>
|
|
27
29
|
|
|
28
30
|
<template>
|
|
@@ -39,6 +41,7 @@ const imageUrl = (url: string) => props.resolveImage?.(url) ?? url
|
|
|
39
41
|
target="_blank"
|
|
40
42
|
rel='noopener noreferrer'
|
|
41
43
|
class="text-default-transparent hover:text-action transition-colors"
|
|
44
|
+
:aria-label="formatMessage(messages.review.linkedin, { name: item.name })"
|
|
42
45
|
:href="item.linkedinurl">
|
|
43
46
|
<component
|
|
44
47
|
:is="LinkedinIcon"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, ref, onMounted, nextTick } from 'vue';
|
|
3
3
|
import Shevron from '../assets/icons/shevron.vue';
|
|
4
4
|
import { type MenuItem } from '../lib/menus/menuTypes.ts';
|
|
5
|
+
import { useBrandMessages } from '../lib/i18n/messages.ts';
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
section?: string;
|
|
@@ -10,6 +11,7 @@ interface Props {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
const props = defineProps<Props>();
|
|
14
|
+
const messages = useBrandMessages();
|
|
13
15
|
|
|
14
16
|
const openItem = ref<string | null>(null);
|
|
15
17
|
|
|
@@ -102,7 +104,7 @@ onMounted(async () => {
|
|
|
102
104
|
</script>
|
|
103
105
|
|
|
104
106
|
<template>
|
|
105
|
-
<nav v-if="items.length" class="ds-subnav" aria-label="
|
|
107
|
+
<nav v-if="items.length" class="ds-subnav" :aria-label="messages.subNavbar.label">
|
|
106
108
|
<div class="ds-subnav-inner" :class="{ 'is-submenu-open': openItem }">
|
|
107
109
|
<ul>
|
|
108
110
|
<li v-for="item in items" :key="item.name" :ref="el => setItemRef(item.name, el)">
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ICrmFormClient,
|
|
16
16
|
} from "../../lib/crm/types";
|
|
17
17
|
import { isCorrectEmailFormat, isCorrectLinkedInFormat } from "../../helpers/form-validator.ts";
|
|
18
|
+
import { formatMessage, useBrandMessages } from "../../lib/i18n/messages.ts";
|
|
18
19
|
|
|
19
20
|
/* This component knows nothing about HubSpot, or about any other CRM. It is
|
|
20
21
|
* handed the labels to render and something that can accept a submission; who
|
|
@@ -24,6 +25,8 @@ const props = defineProps<{
|
|
|
24
25
|
client: ICrmFormClient
|
|
25
26
|
}>()
|
|
26
27
|
|
|
28
|
+
const messages = useBrandMessages()
|
|
29
|
+
|
|
27
30
|
const emit = defineEmits<{
|
|
28
31
|
(e: 'submit', values: ContactFormValues): void
|
|
29
32
|
(e: 'submitted', result: CrmSubmitResult): void
|
|
@@ -86,15 +89,15 @@ const validateForm = () => {
|
|
|
86
89
|
let linkedInPage = formData.value.linkedinCompanyPage;
|
|
87
90
|
|
|
88
91
|
if (email.value !== '' && !isCorrectEmailFormat(email.value)) {
|
|
89
|
-
email.error =
|
|
92
|
+
email.error = messages.contactForm.invalidEmail
|
|
90
93
|
isValid = false
|
|
91
94
|
}
|
|
92
95
|
if (linkedInPage.value !== '' && !isCorrectLinkedInFormat(linkedInPage.value)) {
|
|
93
|
-
linkedInPage.error =
|
|
96
|
+
linkedInPage.error = messages.contactForm.invalidLinkedIn
|
|
94
97
|
isValid = false
|
|
95
98
|
}
|
|
96
99
|
if (description.maxLength !== undefined && description.value.length > description.maxLength) {
|
|
97
|
-
description.error =
|
|
100
|
+
description.error = formatMessage(messages.contactForm.tooLong, { max: description.maxLength })
|
|
98
101
|
isValid = false
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -110,7 +113,7 @@ const validateRequiredFields = () => {
|
|
|
110
113
|
const isEmpty = field.value === '' || field.value === false
|
|
111
114
|
|
|
112
115
|
if (field.required && isEmpty) {
|
|
113
|
-
field.error =
|
|
116
|
+
field.error = messages.contactForm.required
|
|
114
117
|
isValid = false
|
|
115
118
|
} else {
|
|
116
119
|
field.error = ''
|
|
@@ -177,8 +180,8 @@ const alertKind = ref<'success' | 'error' | null>(null)
|
|
|
177
180
|
const isAlertVisible = computed(() => alertKind.value !== null)
|
|
178
181
|
|
|
179
182
|
const ALERT_TEXT = {
|
|
180
|
-
success:
|
|
181
|
-
error:
|
|
183
|
+
success: messages.contactForm.sent,
|
|
184
|
+
error: messages.contactForm.notSent,
|
|
182
185
|
} as const
|
|
183
186
|
|
|
184
187
|
const showAlert = (kind: 'success' | 'error') => {
|
|
@@ -205,7 +208,7 @@ const validateField = (field) => {
|
|
|
205
208
|
}
|
|
206
209
|
|
|
207
210
|
if (field.required && !field.value) {
|
|
208
|
-
field.error =
|
|
211
|
+
field.error = messages.contactForm.required
|
|
209
212
|
return
|
|
210
213
|
}
|
|
211
214
|
|
|
@@ -233,8 +236,14 @@ fields.forEach((key) => {
|
|
|
233
236
|
)
|
|
234
237
|
})
|
|
235
238
|
|
|
236
|
-
|
|
237
|
-
|
|
239
|
+
/* What each field refuses as it is typed. A letter includes its combining
|
|
240
|
+
* marks (\p{M}), and a name keeps its hyphen, apostrophe and period, so
|
|
241
|
+
* "Anne-Marie O'Neil Jr." survives. A company name also keeps the punctuation
|
|
242
|
+
* legal names are written with: the quotes of ООО «Ромашка», and commas,
|
|
243
|
+
* parentheses, slashes and plus signs. Both used to allow letters and spaces
|
|
244
|
+
* alone. */
|
|
245
|
+
const companyNamePattern = /[^\p{L}\p{M}\p{N} &.,'’"«»„“”()+\/!-]/u;
|
|
246
|
+
const namePattern = /[^\p{L}\p{M} .'’-]/u;
|
|
238
247
|
|
|
239
248
|
const createSanitizer = (pattern) => {
|
|
240
249
|
return (value) => value.replace(pattern, '');
|
|
@@ -279,7 +288,7 @@ const preventInvalidInput = (event, pattern) => {
|
|
|
279
288
|
<Checkbox id="promotions" v-model="formData.communicationConsent.value" :label="formData.communicationConsent.label" :isRequired="formData.communicationConsent.required" :isError="!!formData.communicationConsent.error" />
|
|
280
289
|
</div>
|
|
281
290
|
</div>
|
|
282
|
-
<GlowButton @click="submitContactsForm" title="
|
|
291
|
+
<GlowButton @click="submitContactsForm" :title="messages.contactForm.submit" class="self-center lg:self-start" />
|
|
283
292
|
</form>
|
|
284
293
|
<div v-show="isAlertVisible" class="border border-action group absolute bottom-19.25 md:fixed z-350 left-1/2 -translate-x-1/2 md:top-40 form-alert flex gap-2 md:gap-8 items-center justify-between w-full h-fit max-w-139 bg-surface-secondary p-4 md:px-6 md:py-2.5 rounded-3xl">
|
|
285
294
|
<div v-if="alertKind === 'success'" class="w-8 h-8">
|
|
@@ -288,7 +297,7 @@ const preventInvalidInput = (event, pattern) => {
|
|
|
288
297
|
<p class="text-sm md:text-lg font-bold text-heading md:max-w-sm ">
|
|
289
298
|
{{ alertKind ? ALERT_TEXT[alertKind] : '' }}
|
|
290
299
|
</p>
|
|
291
|
-
<button @click="alertKind = null" class="group-hover:text-action text-heading transition-colors cursor-pointer">
|
|
300
|
+
<button @click="alertKind = null" :aria-label="messages.contactForm.closeAlert" class="group-hover:text-action text-heading transition-colors cursor-pointer">
|
|
292
301
|
<component :is="CloseIcon" />
|
|
293
302
|
</button>
|
|
294
303
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import Button from "./Button.vue";
|
|
3
3
|
import { computed } from 'vue'
|
|
4
|
+
import { useBrandMessages } from '../lib/i18n/messages.ts'
|
|
4
5
|
|
|
5
6
|
const props = defineProps<{
|
|
6
7
|
active: boolean
|
|
@@ -10,6 +11,8 @@ const props = defineProps<{
|
|
|
10
11
|
index?: number
|
|
11
12
|
}>()
|
|
12
13
|
|
|
14
|
+
const messages = useBrandMessages()
|
|
15
|
+
|
|
13
16
|
const rotate = [
|
|
14
17
|
'-rotate-4 left-2 top-10',
|
|
15
18
|
'-rotate-1 left-1/4 top-20',
|
|
@@ -46,10 +49,10 @@ const cardClass = computed(() => [
|
|
|
46
49
|
<template>
|
|
47
50
|
<div :class="cardClass">
|
|
48
51
|
<div class="card flex flex-col items-center justify-around">
|
|
49
|
-
<h3 class="
|
|
52
|
+
<h3 class="w-min min-w-[8rem] max-w-full text-center text-xl font-bold text-heading text-balance">
|
|
50
53
|
{{ name }}
|
|
51
54
|
</h3>
|
|
52
|
-
<Button as="link" :href="href ?? ''" title="
|
|
55
|
+
<Button as="link" :href="href ?? ''" :title="messages.technologyCard.explore" variant="tertiary" :class="`${active ? 'block' : 'hidden xl:block xl:opacity-0'}`" />
|
|
53
56
|
</div>
|
|
54
57
|
</div>
|
|
55
58
|
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import Button from "../common/Button.vue";
|
|
3
|
+
import { useId } from "vue";
|
|
3
4
|
|
|
4
5
|
/* Declared here rather than imported from ./links.ts, which is this site's
|
|
5
6
|
* footer content. TypeScript is structural, so every existing caller still
|
|
@@ -15,11 +16,17 @@ defineProps<{
|
|
|
15
16
|
groupName: string
|
|
16
17
|
items: LinkItem[]
|
|
17
18
|
}>()
|
|
19
|
+
|
|
20
|
+
/* The heading's id, generated rather than built from groupName: a group name
|
|
21
|
+
* is display text, and "Отзывы о нас" has spaces, which an id cannot hold, so
|
|
22
|
+
* aria-labelledby named nothing. Astro gives each rendered Vue app its own
|
|
23
|
+
* idPrefix, so two groups on one page never collide. */
|
|
24
|
+
const labelId = useId()
|
|
18
25
|
</script>
|
|
19
26
|
|
|
20
27
|
<template>
|
|
21
|
-
<nav :aria-labelledby="
|
|
22
|
-
<p :id="
|
|
28
|
+
<nav :aria-labelledby="labelId" class="space-y-4">
|
|
29
|
+
<p :id="labelId" class="text-body-secondary uppercase font-bold text-xs" >
|
|
23
30
|
{{ groupName }}
|
|
24
31
|
</p>
|
|
25
32
|
<ul class="space-y-3 xl:space-y-2 text-sm">
|
|
@@ -4,6 +4,7 @@ import Shevron from "../assets/icons/shevron.vue";
|
|
|
4
4
|
import { computed, onUnmounted, ref, watch } from "vue";
|
|
5
5
|
import BackIcon from "../assets/icons/back-icon.vue";
|
|
6
6
|
import ServicesList from "./services-list.vue";
|
|
7
|
+
import { useBrandMessages } from "../lib/i18n/messages.ts";
|
|
7
8
|
|
|
8
9
|
/* Everything this drawer used to read out of menu.ts, paths.ts and the site's
|
|
9
10
|
* logo asset now arrives as props, so it reaches no route table and no asset
|
|
@@ -26,6 +27,8 @@ const props = defineProps<{
|
|
|
26
27
|
homeHref?: string
|
|
27
28
|
}>()
|
|
28
29
|
|
|
30
|
+
const messages = useBrandMessages()
|
|
31
|
+
|
|
29
32
|
const servicesItem = computed(() => props.items.find((i) => i.submenu?.length))
|
|
30
33
|
|
|
31
34
|
const isMenuOpen = ref(false)
|
|
@@ -81,7 +84,7 @@ const handleCloseMenu = () => {
|
|
|
81
84
|
<a :href="homeHref" class="px-1.5 hover:opacity-80 transition-opacity">
|
|
82
85
|
<img :src="logo" alt="CODECAVE" />
|
|
83
86
|
</a>
|
|
84
|
-
<button @click="isMenuOpen = !isMenuOpen" :class="`burger-menu ${isMenuOpen ? 'text-action' : 'text-heading'} transition-colors`">
|
|
87
|
+
<button @click="isMenuOpen = !isMenuOpen" :aria-label="messages.mobileMenu.toggle" :aria-expanded="isMenuOpen" :class="`burger-menu ${isMenuOpen ? 'text-action' : 'text-heading'} transition-colors`">
|
|
85
88
|
<span></span>
|
|
86
89
|
</button>
|
|
87
90
|
</div>
|
|
@@ -94,7 +97,7 @@ const handleCloseMenu = () => {
|
|
|
94
97
|
<slot name="services" />
|
|
95
98
|
</div>
|
|
96
99
|
|
|
97
|
-
<button class="pt-4 shrink-0" @click="isServicesOpen = false">
|
|
100
|
+
<button class="pt-4 shrink-0" :aria-label="messages.mobileMenu.back" @click="isServicesOpen = false">
|
|
98
101
|
<component :is="BackIcon" class="w-11 h-11 text-action" />
|
|
99
102
|
</button>
|
|
100
103
|
</div>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/* The text brand components render that no caller passes in: validation
|
|
2
|
+
* errors, button titles, landmark names and the names of icon-only controls.
|
|
3
|
+
*
|
|
4
|
+
* English is built in, so a consumer that provides nothing renders what these
|
|
5
|
+
* components always rendered. A site in another language hands its translation
|
|
6
|
+
* to provideBrandMessages() once, from its Vue app entrypoint, instead of
|
|
7
|
+
* threading one prop per string through every call site.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE APP ENTRYPOINT AND NOT A PARENT COMPONENT. Astro renders every island,
|
|
10
|
+
* and every component it renders without a client directive, as a separate Vue
|
|
11
|
+
* app, so a provide() in one component reaches nothing in the next. The app
|
|
12
|
+
* entrypoint is the one place each of those apps passes through, on the server
|
|
13
|
+
* and in the browser alike:
|
|
14
|
+
*
|
|
15
|
+
* // astro.config.mjs
|
|
16
|
+
* vue({ appEntrypoint: '/src/pages/_app' })
|
|
17
|
+
*
|
|
18
|
+
* // src/pages/_app.ts
|
|
19
|
+
* export default (app: App) => provideBrandMessages(app, russian)
|
|
20
|
+
*
|
|
21
|
+
* A {name} in a string is filled by formatMessage(). A translation keeps every
|
|
22
|
+
* placeholder and puts it wherever its grammar needs. */
|
|
23
|
+
import { inject, type App, type InjectionKey } from 'vue'
|
|
24
|
+
|
|
25
|
+
export interface BrandMessages {
|
|
26
|
+
contactForm: {
|
|
27
|
+
/** A required field left empty. */
|
|
28
|
+
required: string
|
|
29
|
+
invalidEmail: string
|
|
30
|
+
invalidLinkedIn: string
|
|
31
|
+
/** {max}: the field's character limit. */
|
|
32
|
+
tooLong: string
|
|
33
|
+
/** The submit button. */
|
|
34
|
+
submit: string
|
|
35
|
+
/** The alert after a submission the CRM accepted. */
|
|
36
|
+
sent: string
|
|
37
|
+
/** The alert after a submission that failed. */
|
|
38
|
+
notSent: string
|
|
39
|
+
/** Accessible name of the button that dismisses either alert. */
|
|
40
|
+
closeAlert: string
|
|
41
|
+
}
|
|
42
|
+
articlePreview: {
|
|
43
|
+
/** {minutes}: the article's reading time. */
|
|
44
|
+
readingTime: string
|
|
45
|
+
}
|
|
46
|
+
technologyCard: {
|
|
47
|
+
/** The link to the service a card names. */
|
|
48
|
+
explore: string
|
|
49
|
+
}
|
|
50
|
+
brandNav: {
|
|
51
|
+
/** Accessible name of the top bar's navigation landmark. */
|
|
52
|
+
label: string
|
|
53
|
+
}
|
|
54
|
+
subNavbar: {
|
|
55
|
+
/** Accessible name of the section bar's navigation landmark. */
|
|
56
|
+
label: string
|
|
57
|
+
}
|
|
58
|
+
mobileMenu: {
|
|
59
|
+
/** Accessible name of the burger button that opens and closes the drawer. */
|
|
60
|
+
toggle: string
|
|
61
|
+
/** Accessible name of the button that leaves the services list. */
|
|
62
|
+
back: string
|
|
63
|
+
}
|
|
64
|
+
review: {
|
|
65
|
+
/** {name}: the reviewer. Accessible name of the link to their LinkedIn. */
|
|
66
|
+
linkedin: string
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Any subset of the strings, by group. What is left out stays English. */
|
|
71
|
+
export type BrandMessagesOverride = {
|
|
72
|
+
[Group in keyof BrandMessages]?: Partial<BrandMessages[Group]>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const ENGLISH_BRAND_MESSAGES: BrandMessages = {
|
|
76
|
+
contactForm: {
|
|
77
|
+
required: 'This field is required',
|
|
78
|
+
invalidEmail: 'Please enter valid email',
|
|
79
|
+
invalidLinkedIn: 'Please enter valid LinkedIn page link',
|
|
80
|
+
tooLong: 'You faced characters limits. Max length is {max}',
|
|
81
|
+
submit: 'Leave consultation request',
|
|
82
|
+
sent: 'Sent! We will contact you within next 1-3 business days.',
|
|
83
|
+
notSent: 'We could not send your request. Please try again, or email us at hello@codecave.pro.',
|
|
84
|
+
closeAlert: 'Close',
|
|
85
|
+
},
|
|
86
|
+
articlePreview: {
|
|
87
|
+
readingTime: 'Reading time: {minutes} m.',
|
|
88
|
+
},
|
|
89
|
+
technologyCard: {
|
|
90
|
+
explore: 'Explore service',
|
|
91
|
+
},
|
|
92
|
+
brandNav: {
|
|
93
|
+
label: 'Main',
|
|
94
|
+
},
|
|
95
|
+
subNavbar: {
|
|
96
|
+
label: 'Section',
|
|
97
|
+
},
|
|
98
|
+
mobileMenu: {
|
|
99
|
+
toggle: 'Menu',
|
|
100
|
+
back: 'Back',
|
|
101
|
+
},
|
|
102
|
+
review: {
|
|
103
|
+
linkedin: '{name} on LinkedIn',
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* Symbol.for rather than Symbol(): the key has to be the same value in the
|
|
108
|
+
* module the site's entrypoint imports and the one each component imports, and
|
|
109
|
+
* a consumer that aliases this package at a local build can load it twice. */
|
|
110
|
+
const KEY = Symbol.for('codecave.brand.messages') as InjectionKey<BrandMessages>
|
|
111
|
+
|
|
112
|
+
/** Makes every brand component in `app` render `messages`, English where they leave a string out. */
|
|
113
|
+
export const provideBrandMessages = (app: App, messages: BrandMessagesOverride): void => {
|
|
114
|
+
const merged = Object.fromEntries(
|
|
115
|
+
Object.entries(ENGLISH_BRAND_MESSAGES).map(([group, strings]) => [
|
|
116
|
+
group,
|
|
117
|
+
{ ...strings, ...messages[group as keyof BrandMessages] },
|
|
118
|
+
]),
|
|
119
|
+
) as unknown as BrandMessages
|
|
120
|
+
app.provide(KEY, merged)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The strings a component renders. Call it in setup. */
|
|
124
|
+
export const useBrandMessages = (): BrandMessages => inject(KEY, ENGLISH_BRAND_MESSAGES)
|
|
125
|
+
|
|
126
|
+
/** Fills the {name} placeholders of a message. A placeholder with no value is left as written. */
|
|
127
|
+
export const formatMessage = (template: string, values: Record<string, string | number>): string =>
|
|
128
|
+
template.replace(/\{(\w+)\}/g, (placeholder, name: string) =>
|
|
129
|
+
Object.hasOwn(values, name) ? String(values[name]) : placeholder)
|
package/dist/theme.css
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* come from the production `@theme` block; the remaining steps were measured
|
|
7
7
|
* from the rendered site.
|
|
8
8
|
*
|
|
9
|
-
* One family, no display face, no serif
|
|
9
|
+
* One family, no display face, no serif. Manrope follows Satoshi in the stack
|
|
10
|
+
* only for the Cyrillic Satoshi lacks; see its @font-face in colors_and_type.css.
|
|
10
11
|
*/
|
|
11
12
|
export declare const fontFamily: {
|
|
12
|
-
readonly sans: "Satoshi, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", Arial, sans-serif";
|
|
13
|
+
readonly sans: "Satoshi, Manrope, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", Arial, sans-serif";
|
|
13
14
|
readonly mono: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace";
|
|
14
15
|
};
|
|
15
16
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"typography.d.ts","sourceRoot":"","sources":["../../tokens/typography.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"typography.d.ts","sourceRoot":"","sources":["../../tokens/typography.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,eAAO,MAAM,UAAU;;;CAGb,CAAC;AAEX;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUX,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,UAAU;;;;;;CAMb,CAAC;AAEX;;;;;GAKG;AACH,eAAO,MAAM,OAAO;;;;;;;CAOV,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,MAAM,OAAO,QAAQ,CAAC;AACjD,MAAM,MAAM,UAAU,GAAG,MAAM,OAAO,UAAU,CAAC"}
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* come from the production `@theme` block; the remaining steps were measured
|
|
7
7
|
* from the rendered site.
|
|
8
8
|
*
|
|
9
|
-
* One family, no display face, no serif
|
|
9
|
+
* One family, no display face, no serif. Manrope follows Satoshi in the stack
|
|
10
|
+
* only for the Cyrillic Satoshi lacks; see its @font-face in colors_and_type.css.
|
|
10
11
|
*/
|
|
11
12
|
export const fontFamily = {
|
|
12
|
-
sans: 'Satoshi, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif',
|
|
13
|
+
sans: 'Satoshi, Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif',
|
|
13
14
|
mono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
14
15
|
};
|
|
15
16
|
/**
|
package/dist/tokens.css
CHANGED
|
@@ -106,11 +106,12 @@
|
|
|
106
106
|
|
|
107
107
|
/* =========================================================================
|
|
108
108
|
* TYPOGRAPHY
|
|
109
|
-
* One family
|
|
110
|
-
*
|
|
109
|
+
* One family, and Manrope for the Cyrillic it lacks (see "Cyrillic" above).
|
|
110
|
+
* Named heading sizes come from the production @theme block; the remaining
|
|
111
|
+
* steps are measured from the live site.
|
|
111
112
|
* ====================================================================== */
|
|
112
|
-
--font-sans: Satoshi, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
113
|
-
"Helvetica Neue", "Noto Sans", Arial, sans-serif;
|
|
113
|
+
--font-sans: Satoshi, Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
114
|
+
Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif;
|
|
114
115
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
|
115
116
|
"Liberation Mono", "Courier New", monospace;
|
|
116
117
|
|
package/package.json
CHANGED