gdk-toogle 0.9.3 → 1.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b41975a3c2a35c12eecaad91aa7bfd949d68d0132576d1f68ecca48987abf641
4
- data.tar.gz: ad9ede84ae8f37f9286699d33163f360a04854a00abb3fbfb4ec6b07e3ec386f
3
+ metadata.gz: 5b7fb4c9b37ce9f5712cc5635ff39bea2b3745179ea554dbe4b941f2a9472cab
4
+ data.tar.gz: 57a6c432b93d5dc0e88c52f46c653ca26338638f99f866c57e56dc5540a21692
5
5
  SHA512:
6
- metadata.gz: 20a49dff67060c27ab608f9851570494726856f34e0deb11ad3c60266b72a3af13260384c63ddf3afe3284449e9984e03f3fd345a9302c6e132760a515c124eb
7
- data.tar.gz: 1d372ec1b321404ad287154a027aef0a1732116ffedd449602bcd57f57e4d4d77b0a0e5ea11f2e5ed5e7c5caf3901ae99569485f3db8c0e50c7bc5f581ba5957
6
+ metadata.gz: da4f86f837651e78dc12c7c61e2563cb8e099ff5a6a454e00226728906427243649190743cc36b46bcac34967551542afe8da58f5f5a35e5c6fd834412d434e4
7
+ data.tar.gz: 807b565d41e6f131a0811fb7e4d980f1c5a0a5ad5a61c892e81f3c9e6d4c2c614fe269bfadf8087465467deca63d3297080366ad1827b4abd302a09c5fb30e21
data/README.md CHANGED
@@ -4,30 +4,93 @@ A Rails engine web-UI to quickly toggle feature flags in GDK.
4
4
 
5
5
  ## Usage
6
6
 
7
- This gem was written specifically for the GitLab codebase. It won't work in any other Rails app.
7
+ This gem was written specifically for the GitLab codebase. It relies on
8
+ GitLab's `Feature` classes, so mounting it in any other Rails app won't work
9
+ (for developing Toogle itself there is a standalone dummy app, see below).
8
10
 
9
11
  ### Usage within the GitLab Development Kit
10
12
 
11
- This gem is still an experiment and not yet part of our `Gemfile`. So for now, to try out this gem, take the following steps:
13
+ The UI is available at http://gdk.test:3000/rails/features.
12
14
 
13
- - `git clone` this repository to your machine.
14
- - Add `gem 'toogle', path: 'local_toogle_path_here'` to the Gemfile and `bundle`.
15
- - Add `mount Toogle::Engine, at: '/rails/toogle'` to `config/routes/development.rb`.
16
- - Restart the rails server to pick up the new route: `gdk restart rails-web`
15
+ ## Contributing
17
16
 
18
- The UI is now availabe at http://gdk.test:3000/rails/toogle.
17
+ Everyone can contribute. Open issues and merge requests at
18
+ https://gitlab.com/thutterer/toogle. Please make sure `rake` (specs and
19
+ linter) passes before submitting.
19
20
 
20
- ## Contributing
21
+ There is an `AGENTS.md` with the conventions and architecture notes that
22
+ keep this codebase simple — worth a read, and keep it accurate when you
23
+ change things.
24
+
25
+ ### How to run it standalone
26
+
27
+ The dummy app used for testing includes fake in-memory versions of GitLab's
28
+ `Feature` and Flipper classes (see `spec/dummy/app/models/`), so you can
29
+ develop the engine without a GDK:
30
+
31
+ ```sh
32
+ cd spec/dummy
33
+ bin/rails server
34
+ ```
35
+
36
+ Then open http://localhost:3000/toogle. Toggled state lives in memory and
37
+ resets on restart.
38
+
39
+ To test your changes inside a real GDK, point GitLab's `Gemfile` at your
40
+ local checkout:
41
+
42
+ ```ruby
43
+ gem 'gdk-toogle', path: '~/Code/toogle', require: 'toogle'
44
+ ```
45
+
46
+ ### How to run tests and linting
47
+
48
+ Run `rake` to run both the specs and the [standard](https://github.com/standardrb/standard)
49
+ linter. They are also available separately as `rake spec` and `rake standard`
50
+ (use `rake standard:fix` to autoformat).
51
+
52
+ To lint automatically before each commit, enable the bundled git hook once:
53
+
54
+ ```sh
55
+ git config core.hooksPath .githooks
56
+ ```
57
+
58
+ The system specs in `spec/system` use headless Firefox by default. Set
59
+ `CAPYBARA_DRIVER` to any registered Capybara driver to switch, e.g. if you
60
+ don't have Firefox or only a Snap-installed one (incompatible with
61
+ geckodriver):
62
+
63
+ ```sh
64
+ CAPYBARA_DRIVER=selenium_chrome_headless bundle exec rspec
65
+ ```
66
+
67
+ ### How to cut a release
68
+
69
+ 1. Bump `Toogle::VERSION` in `lib/toogle/version.rb`.
70
+ 2. Add an entry to `CHANGELOG.md`.
71
+ 3. Commit, then tag: `git tag vX.Y.Z && git push --tags`.
72
+ 4. Build and push the gem: `rake build` then `gem push pkg/gdk-toogle-X.Y.Z.gem`.
73
+ 5. Bump the version constraint in GitLab's `Gemfile` to pick it up.
74
+
75
+ Keep Ruby and gem versions at or below what GitLab itself runs (see
76
+ `AGENTS.md`).
77
+
78
+ ## Tech stack
79
+
80
+ One design goal of this little Rails engine was to keep it free of any dependencies, that GitLab uses today.
81
+ Because, tomorrow we might decide to ditch one or the other tech we use today, and Toogle shouldn't break just because GitLab removes `sassc`, as an example (that actually happened during development).
82
+
83
+ That's why Toogle only depends on the bare basics: `rails`, `sprockets` and `haml`.
21
84
 
22
- Everyone can contribute.
85
+ ### Javascript
23
86
 
24
- ### How to run tests
87
+ Toogle uses [Alpine.js](https://alpinejs.dev/) from a `<script>` tag so it does not use or depend on our Vue.js usage or any bundler. Alpine is very similar to Vue, so it's really easy to pick up. Give it a try!
25
88
 
26
- This is the default rake task. Just run `rake` with no arguments.
89
+ ### CSS
27
90
 
28
- The system specs in `spec/system` are configured to use Firefox as headless browser.
91
+ Toogle uses native unprocessed CSS to not depend on any specific preprocessor. Modern CSS is awesome, and all modern browsers support things like nested selectors today.
29
92
 
30
- **Tip:** If you don't have Firefox installed on your machine, or if you are on Ubuntu and have Firefox installed only via Snap, you can switch to Chrome by changing the `driven_by` to `:selenium_chrome_headless` in `spec/rails_helper.rb`.
93
+ For theming and dark mode, it uses CSS variables and HSL colors, which makes it super easy to switch from light to dark by just changing the variable that makes up the _Lightness_. Check it out!
31
94
 
32
95
  ## License
33
96
 
data/Rakefile CHANGED
@@ -13,4 +13,7 @@ require "rspec/core/rake_task"
13
13
  desc "Run all specs in spec directory"
14
14
  RSpec::Core::RakeTask.new(:spec)
15
15
 
16
- task default: :spec
16
+ # Provides rake tasks `standard` and `standard:fix`
17
+ require "standard/rake"
18
+
19
+ task default: %i[spec standard]
@@ -1,10 +1,41 @@
1
1
  document.addEventListener("alpine:init", () => {
2
- Alpine.data("features", (indexUrl) => ({
2
+ Alpine.store("flash", {
3
+ message: "",
4
+
5
+ show(message) {
6
+ this.message = message;
7
+ },
8
+ });
9
+
10
+ const showFetchError = (error) => {
11
+ console.error("Error fetching data:", error);
12
+ Alpine.store("flash").show(
13
+ "Could not reach the server. Is your GDK running?"
14
+ );
15
+ };
16
+
17
+ const csrfToken = () =>
18
+ document.head.querySelector("meta[name=csrf-token]")?.content;
19
+
20
+ // Cap how many Defaults render at once. This is a search tool — the wanted
21
+ // flag is reachable by typing — so we never build the full ~700+ row DOM.
22
+ const MAX_DEFAULTS_SHOWN = 50;
23
+
24
+ Alpine.data("flags", (featuresUrl, definitionsUrl, shareName = "") => ({
3
25
  features: [],
4
- indexUrl,
26
+ definitions: [],
27
+ query: "",
28
+ lastMoved: "",
29
+ moving: "",
30
+ selectedName: "",
31
+ max: MAX_DEFAULTS_SHOWN,
32
+ featuresUrl: featuresUrl.replace(/\/$/, ""),
33
+ definitionsUrl,
5
34
 
6
35
  init() {
7
- fetch(this.indexUrl, {
36
+ const featuresLoaded = this.fetchFeatures();
37
+
38
+ const definitionsLoaded = fetch(this.definitionsUrl, {
8
39
  method: "GET",
9
40
  headers: {
10
41
  Accept: "application/json",
@@ -12,70 +43,174 @@ document.addEventListener("alpine:init", () => {
12
43
  })
13
44
  .then((response) => response.json())
14
45
  .then((data) => {
15
- this.features = data;
46
+ this.definitions = data;
16
47
  })
17
- .catch((error) => console.error("Error fetching data:", error));
48
+ .catch(showFetchError);
49
+
50
+ if (shareName) {
51
+ Promise.all([featuresLoaded, definitionsLoaded]).then(() =>
52
+ this.openDetails(shareName)
53
+ );
54
+ }
18
55
  },
19
56
 
20
- toggleFeature(feature) {
21
- const newState = feature.state === "enabled" ? "disabled" : "enabled";
22
- fetch(`${this.indexUrl}/${feature.name}`, {
23
- method: "PUT",
57
+ openDetails(name) {
58
+ this.selectedName = name;
59
+ history.replaceState({}, "", `${this.featuresUrl}/${name}`);
60
+ this.$refs.dialog.showModal();
61
+ },
62
+
63
+ closeDetails() {
64
+ this.selectedName = "";
65
+ history.replaceState({}, "", `${this.featuresUrl}/`);
66
+ },
67
+
68
+ // The dialog's subject, looked up live so it stays in sync after toggles.
69
+ selected() {
70
+ const feature = this.features.find((f) => f.name === this.selectedName);
71
+ if (feature) {
72
+ return {
73
+ state: feature.state,
74
+ definition: feature.definition,
75
+ };
76
+ }
77
+
78
+ const definition = this.definitions.find(
79
+ (d) => d.name === this.selectedName
80
+ );
81
+ if (definition) {
82
+ return {
83
+ state: definition.default_enabled ? "enabled" : "disabled",
84
+ definition,
85
+ };
86
+ }
87
+
88
+ return null;
89
+ },
90
+
91
+ toggleSelected() {
92
+ const feature = this.features.find((f) => f.name === this.selectedName);
93
+
94
+ if (feature) {
95
+ this.toggleFeature(feature);
96
+ } else {
97
+ this.toggleDefault(this.selected().definition);
98
+ }
99
+ },
100
+
101
+ fetchFeatures(movedName = "") {
102
+ return fetch(this.featuresUrl, {
103
+ method: "GET",
24
104
  headers: {
25
105
  Accept: "application/json",
26
- "Content-Type": "application/json",
27
106
  },
28
- body: JSON.stringify({
29
- state: newState,
30
- }),
31
- }).then(() => {
107
+ })
108
+ .then((response) => response.json())
109
+ .then((data) => {
110
+ if (movedName) {
111
+ this.animated(() => {
112
+ this.features = data;
113
+ }, movedName);
114
+ } else {
115
+ // Not animated: the initial load should not "fly in" rows.
116
+ this.features = data;
117
+ }
118
+ })
119
+ .catch(showFetchError);
120
+ },
121
+
122
+ // Animates the layout change, e.g. a row moving between the sections.
123
+ // Only the moving row gets a view-transition-name (see the view), so the
124
+ // browser snapshots one row, not all ~700 — keeping toggles fast no
125
+ // matter how many flags exist. The moved row then gets a short "shine".
126
+ async animated(applyChange, movedName = "") {
127
+ if (!document.startViewTransition || !movedName) return applyChange();
128
+
129
+ // Name the moving row before the browser snapshots the old state.
130
+ this.moving = movedName;
131
+ await this.$nextTick();
132
+
133
+ const transition = document.startViewTransition(() => {
134
+ applyChange();
135
+ return this.$nextTick();
136
+ });
137
+
138
+ await transition.finished.catch(() => {});
139
+ this.moving = "";
140
+ this.lastMoved = movedName;
141
+ setTimeout(() => {
142
+ if (this.lastMoved === movedName) this.lastMoved = "";
143
+ }, 1200);
144
+ },
145
+
146
+ filteredFeatures() {
147
+ return this.features.filter((f) => f.name.includes(this.query.trim()));
148
+ },
149
+
150
+ // All definitions matching the query, minus those already customized.
151
+ matchingDefinitions() {
152
+ const customized = this.features.map((f) => f.name);
153
+ return this.definitions.filter(
154
+ (d) =>
155
+ d.name.includes(this.query.trim()) && !customized.includes(d.name)
156
+ );
157
+ },
158
+
159
+ // Only the rows we actually render (capped); see MAX_DEFAULTS_SHOWN.
160
+ filteredDefinitions() {
161
+ return this.matchingDefinitions().slice(0, this.max);
162
+ },
163
+
164
+ defaultsCapped() {
165
+ return this.matchingDefinitions().length > this.max;
166
+ },
167
+
168
+ toggleFeature(feature) {
169
+ const newState = feature.state === "enabled" ? "disabled" : "enabled";
170
+
171
+ this.putState(feature.name, newState).then(() => {
32
172
  this.features = this.features.map((f) => {
33
173
  return f === feature ? { ...f, state: newState } : f;
34
174
  });
35
175
  });
36
176
  },
37
177
 
38
- deleteFeature(featureName) {
39
- const csrfToken = document.head.querySelector(
40
- "meta[name=csrf-token]"
41
- )?.content;
178
+ toggleDefault(definition) {
179
+ const newState = definition.default_enabled ? "disabled" : "enabled";
42
180
 
43
- fetch(`${this.indexUrl}/${featureName}`, {
44
- method: "DELETE",
181
+ this.putState(definition.name, newState).then(() =>
182
+ this.fetchFeatures(definition.name)
183
+ );
184
+ },
185
+
186
+ putState(featureName, state) {
187
+ return fetch(`${this.featuresUrl}/${featureName}`, {
188
+ method: "PUT",
45
189
  headers: {
46
190
  Accept: "application/json",
47
- "X-CSRF-Token": csrfToken,
191
+ "Content-Type": "application/json",
192
+ "X-CSRF-Token": csrfToken(),
48
193
  },
49
- }).then(() => {
50
- this.features = this.features.filter((f) => f.name !== featureName);
51
- });
194
+ body: JSON.stringify({ state }),
195
+ }).catch(showFetchError);
52
196
  },
53
- }));
54
197
 
55
- Alpine.data("toggle", (featureName, isChecked, indexUrl) => ({
56
- name: featureName,
57
- checked: isChecked,
58
- indexUrl,
59
-
60
- input: {
61
- ["@change"]() {
62
- const csrfToken = document.head.querySelector(
63
- "meta[name=csrf-token]"
64
- )?.content;
65
-
66
- fetch(`${this.indexUrl}/${this.name}`, {
67
- method: "PUT",
68
- headers: {
69
- "Content-Type": "application/json",
70
- "X-CSRF-Token": csrfToken,
71
- },
72
- body: JSON.stringify({
73
- state: this.checked ? "disabled" : "enabled",
74
- }),
75
- }).then(() => {
76
- window.location = this.indexUrl;
77
- });
78
- },
198
+ deleteFeature(featureName) {
199
+ fetch(`${this.featuresUrl}/${featureName}`, {
200
+ method: "DELETE",
201
+ headers: {
202
+ Accept: "application/json",
203
+ "X-CSRF-Token": csrfToken(),
204
+ },
205
+ })
206
+ .then(() => {
207
+ this.animated(() => {
208
+ this.features = this.features.filter(
209
+ (f) => f.name !== featureName
210
+ );
211
+ }, featureName);
212
+ })
213
+ .catch(showFetchError);
79
214
  },
80
215
  }));
81
216
 
@@ -4,15 +4,13 @@
4
4
  @import "layout";
5
5
  @import "utilities";
6
6
 
7
- @import "components/card";
8
- @import "components/scrollbox";
9
7
  @import "components/toggle";
10
8
 
11
9
  [x-cloak] {
12
10
  display: none !important;
13
11
  }
14
12
 
15
- #notice {
13
+ .notice {
16
14
  background-color: #fc6d26;
17
15
  color: white;
18
16
  padding: 0.5rem;
@@ -4,6 +4,7 @@
4
4
  width: 60px;
5
5
  height: 34px;
6
6
  margin: 4px;
7
+ flex-shrink: 0;
7
8
 
8
9
  input {
9
10
  opacity: 0;
@@ -14,11 +15,9 @@
14
15
  .handle {
15
16
  position: absolute;
16
17
  cursor: pointer;
17
- top: 0;
18
- left: 0;
19
- right: 0;
20
- bottom: 0;
21
- background-color: #ccc;
18
+ inset: 0;
19
+ background-color: var(--color-track);
20
+ box-shadow: inset 0 1px 3px var(--color-shadow);
22
21
  transition: 0.2s;
23
22
 
24
23
  &:before {
@@ -28,10 +27,19 @@
28
27
  width: 26px;
29
28
  left: 4px;
30
29
  bottom: 4px;
31
- background-color: white;
30
+ background-color: var(--color-thumb);
31
+ box-shadow: 0 1px 2px var(--color-shadow);
32
32
  transition: 0.2s;
33
33
  }
34
34
 
35
+ &:hover:before {
36
+ filter: brightness(1.1);
37
+ }
38
+
39
+ &:active:before {
40
+ transform: scale(0.92);
41
+ }
42
+
35
43
  &.round {
36
44
  border-radius: 34px;
37
45
  }
@@ -41,30 +49,37 @@
41
49
  }
42
50
  }
43
51
 
44
- input:checked + .handle {
52
+ input:checked + .handle:before {
45
53
  background-color: var(--color-primary);
54
+ transform: translateX(26px);
46
55
  }
47
56
 
48
- input:focus + .handle {
49
- outline: 4px solid var(--color-primary);
50
- }
51
-
52
- input:checked + .handle:before {
53
- -webkit-transform: translateX(26px);
54
- -ms-transform: translateX(26px);
55
- transform: translateX(26px);
57
+ input:checked + .handle:active:before {
58
+ transform: translateX(26px) scale(0.92);
56
59
  }
57
60
 
58
- input:disabled + .handle:before {
59
- background-color: #ddd;
60
- content: "N/A";
61
- text-align: center;
62
- color: white;
63
- font-size: x-small;
64
- line-height: 26px;
61
+ input:focus-visible + .handle {
62
+ outline: 2px solid var(--color-primary);
63
+ outline-offset: 2px;
65
64
  }
66
65
 
66
+ /* Truly disabled (e.g. flag unknown on this branch): flat and washed out
67
+ into the page, nothing raised or sunken that could look clickable. */
67
68
  input:disabled + .handle {
68
69
  cursor: not-allowed;
70
+ background-color: var(--color-disabled);
71
+ box-shadow: none;
72
+
73
+ &:before,
74
+ &:hover:before {
75
+ content: "N/A";
76
+ text-align: center;
77
+ color: var(--color-text-muted);
78
+ font-size: x-small;
79
+ line-height: 26px;
80
+ background-color: transparent;
81
+ box-shadow: none;
82
+ filter: none;
83
+ }
69
84
  }
70
85
  }
@@ -1,11 +1,11 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
1
5
  html {
2
6
  color: var(--color-text);
3
7
  transition: 0.2s;
4
- background-image: radial-gradient(
5
- circle at 10vh 10vw,
6
- hsl(var(--hue-primary), var(--saturation-bg), var(--lightness-bg1)),
7
- hsl(var(--hue-primary), var(--saturation-bg), var(--lightness-bg0))
8
- );
8
+ background-color: var(--color-page-bg);
9
9
  }
10
10
 
11
11
  body {
@@ -44,11 +44,6 @@ footer a {
44
44
  color: var(--color-text);
45
45
  }
46
46
 
47
- input[type="submit"]:not(.small) {
48
- font-size: large;
49
- padding: 0.25rem;
50
- }
51
-
52
47
  input[type="search"],
53
48
  input[type="text"] {
54
49
  margin: 0.25rem 0;
@@ -71,18 +66,26 @@ input[type="text"] {
71
66
 
72
67
  input[type="search"],
73
68
  input[type="text"] {
74
- color: hsl(var(--hue-primary), var(--saturation-bg), var(--lightness-fg));
75
- background-color: hsl(
76
- var(--hue-primary),
77
- var(--saturation-bg),
78
- var(--lightness-bg2)
79
- );
69
+ color: var(--color-text);
70
+ background-color: var(--color-surface);
80
71
  border: none;
81
- box-shadow: inset 1px 1px 2px var(--color-shadow);
72
+ box-shadow: inset 0 1px 3px var(--color-shadow);
73
+ transition: filter 0.15s;
74
+
75
+ &:not(:read-only):hover {
76
+ filter: brightness(1.05);
77
+ }
82
78
  }
83
79
 
84
80
  hr {
81
+ width: 100%;
85
82
  margin: 1.5rem 0;
83
+ border: none;
84
+ border-top: 1px solid var(--color-track);
85
+ }
86
+
87
+ ::selection {
88
+ background-color: hsl(var(--hue-primary), 90%, 50%, 0.3);
86
89
  }
87
90
 
88
91
  code {
@@ -94,7 +97,7 @@ button {
94
97
  cursor: pointer;
95
98
  background-color: transparent;
96
99
  border: none;
97
- border-radius: 0.125rem;
100
+ border-radius: 0.5rem;
98
101
  padding: 0.5rem;
99
102
  min-width: 1rem;
100
103
 
@@ -103,19 +106,36 @@ button {
103
106
  justify-content: center;
104
107
 
105
108
  font-size: large;
109
+ transition: 0.15s;
106
110
 
107
- &:hover,
108
- &:focus {
111
+ &:hover {
109
112
  background-color: var(--color-primary);
110
113
  color: white;
111
114
  }
112
115
 
113
- &:focus {
116
+ /* Keyboard focus only: an offset ring, no fill, so a focused button and
117
+ a hovered neighbor stay clearly two separate things. */
118
+ &:focus-visible {
114
119
  outline: 2px solid var(--color-primary);
120
+ outline-offset: 2px;
115
121
  opacity: 1 !important;
116
122
  }
117
123
  }
118
124
 
125
+ .badge {
126
+ font-size: 0.75rem;
127
+ font-weight: bold;
128
+ padding: 0.125rem 0.5rem;
129
+ border-radius: 1rem;
130
+ background-color: var(--color-surface);
131
+ box-shadow: 0 1px 2px var(--color-shadow);
132
+
133
+ &.type {
134
+ background-color: var(--color-primary);
135
+ color: white;
136
+ }
137
+ }
138
+
119
139
  ul {
120
140
  margin: 0;
121
141
  padding: 0;
@@ -135,11 +155,60 @@ dialog {
135
155
  border: none;
136
156
  outline: none;
137
157
 
158
+ /* Zoom/fade the dialog in and out. Browsers without @starting-style or
159
+ allow-discrete simply show and hide it instantly. */
160
+ opacity: 0;
161
+ transform: scale(0.95);
162
+ transition: opacity 0.2s ease-out, transform 0.2s ease-out,
163
+ display 0.2s allow-discrete, overlay 0.2s allow-discrete;
164
+
165
+ &[open] {
166
+ opacity: 1;
167
+ transform: scale(1);
168
+
169
+ @starting-style {
170
+ opacity: 0;
171
+ transform: scale(0.95);
172
+ }
173
+ }
174
+
138
175
  &::backdrop {
176
+ background-color: rgba(0, 0, 0, 0);
177
+ transition: background-color 0.2s ease-out,
178
+ display 0.2s allow-discrete, overlay 0.2s allow-discrete;
179
+ }
180
+
181
+ &[open]::backdrop {
139
182
  background-color: rgba(0, 0, 0, 0.5);
183
+
184
+ @starting-style {
185
+ background-color: rgba(0, 0, 0, 0);
186
+ }
140
187
  }
141
188
 
142
- > .card {
143
- margin: 0;
189
+ > .details {
190
+ background-color: var(--color-page-bg);
191
+ border-radius: 0.75rem;
192
+ padding: 1.5rem;
193
+ max-width: 100%;
194
+
195
+ header {
196
+ margin-bottom: 1rem;
197
+
198
+ h2 {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 0.5rem;
202
+ }
203
+
204
+ .badges {
205
+ margin-top: 0.5rem;
206
+ gap: 0.5rem;
207
+ }
208
+ }
209
+
210
+ main {
211
+ gap: 0.5rem;
212
+ }
144
213
  }
145
214
  }