@simmalugnt-se/payload-content-health 0.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.
@@ -0,0 +1 @@
1
+ export { ContentHealthWidget } from "../widget/ContentHealthWidget.js";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Looks up a dot path in a collection's field config, so a rule knows whether its field exists and
3
+ * whether it is localized. Rows, collapsibles and unnamed tabs add no path segment; groups and named
4
+ * tabs do.
5
+ */
6
+ export type FieldLike = {
7
+ /** Unset on the entries of a tabs field. */
8
+ type?: string;
9
+ name?: string;
10
+ localized?: boolean;
11
+ fields?: FieldLike[];
12
+ tabs?: FieldLike[];
13
+ blocks?: BlockLike[];
14
+ blockReferences?: (BlockLike | string)[];
15
+ };
16
+ export type BlockLike = {
17
+ slug: string;
18
+ fields: FieldLike[];
19
+ };
20
+ export type PathInfo = {
21
+ field: FieldLike;
22
+ localized: boolean;
23
+ select: Record<string, unknown>;
24
+ };
25
+ /** Undefined when no field sits at the path. */
26
+ export declare function lookupPath(fields: FieldLike[], path: string): PathInfo | undefined;
27
+ /** Deep-merges Local API `select` objects. */
28
+ export declare function mergeSelect(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
29
+ /** A text-bearing field in a block, by its path in the block; arrays and groups add segments. */
30
+ export type TextField = {
31
+ path: string;
32
+ kind: "text" | "richText";
33
+ };
34
+ /** Where a page body keeps its text: one rich text field, or text fields per block. */
35
+ export type ContentSchema = {
36
+ kind: "richText";
37
+ } | {
38
+ kind: "blocks";
39
+ blocks: Record<string, TextField[]>;
40
+ };
41
+ /**
42
+ * The text fields of a page body field. `localized` is set when any of them is localized, so the
43
+ * body is read once per locale. Undefined for fields that are neither blocks nor rich text.
44
+ */
45
+ export declare function contentSchema(field: FieldLike, resolveBlock: (slug: string) => BlockLike | undefined): {
46
+ schema: ContentSchema;
47
+ localized: boolean;
48
+ } | undefined;
package/dist/fields.js ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Looks up a dot path in a collection's field config, so a rule knows whether its field exists and
3
+ * whether it is localized. Rows, collapsibles and unnamed tabs add no path segment; groups and named
4
+ * tabs do.
5
+ */
6
+ /** Undefined when no field sits at the path. */
7
+ export function lookupPath(fields, path) {
8
+ const segments = path.split(".");
9
+ let current = fields;
10
+ let localized = false;
11
+ for (const [index, segment] of segments.entries()) {
12
+ const field = findNamed(current, segment);
13
+ if (!field) {
14
+ return undefined;
15
+ }
16
+ localized ||= Boolean(field.localized);
17
+ const last = index === segments.length - 1;
18
+ if (last) {
19
+ return { field, localized, select: selectFor(segments) };
20
+ }
21
+ if (!field.fields) {
22
+ return undefined;
23
+ }
24
+ current = field.fields;
25
+ }
26
+ return undefined;
27
+ }
28
+ function findNamed(fields, name) {
29
+ for (const field of fields) {
30
+ if (field.name === name) {
31
+ return field;
32
+ }
33
+ if (field.name) {
34
+ continue;
35
+ }
36
+ // Unnamed containers: row, collapsible, unnamed tab, and the tabs field itself.
37
+ const nested = field.type === "tabs" ? field.tabs : field.fields;
38
+ const found = nested && findNamed(nested, name);
39
+ if (found) {
40
+ return found;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+ function selectFor(segments) {
46
+ const [head, ...rest] = segments;
47
+ return { [head]: rest.length ? selectFor(rest) : true };
48
+ }
49
+ /** Deep-merges Local API `select` objects. */
50
+ export function mergeSelect(target, source) {
51
+ for (const [key, value] of Object.entries(source)) {
52
+ const existing = target[key];
53
+ if (existing === true || value === true) {
54
+ target[key] = true;
55
+ }
56
+ else if (existing && typeof existing === "object") {
57
+ mergeSelect(existing, value);
58
+ }
59
+ else {
60
+ target[key] = value;
61
+ }
62
+ }
63
+ return target;
64
+ }
65
+ /**
66
+ * The text fields of a page body field. `localized` is set when any of them is localized, so the
67
+ * body is read once per locale. Undefined for fields that are neither blocks nor rich text.
68
+ */
69
+ export function contentSchema(field, resolveBlock) {
70
+ if (field.type === "richText") {
71
+ return { schema: { kind: "richText" }, localized: Boolean(field.localized) };
72
+ }
73
+ if (field.type !== "blocks") {
74
+ return undefined;
75
+ }
76
+ const blocks = {};
77
+ let localized = Boolean(field.localized);
78
+ const references = (field.blockReferences ?? []).map((block) => typeof block === "string" ? resolveBlock(block) : block);
79
+ for (const block of [...(field.blocks ?? []), ...references]) {
80
+ if (!block) {
81
+ continue;
82
+ }
83
+ const found = [];
84
+ localized = collectTextFields(block.fields, "", found) || localized;
85
+ blocks[block.slug] = found;
86
+ }
87
+ return { schema: { kind: "blocks", blocks }, localized };
88
+ }
89
+ /** Collects text fields in config order; returns whether any of them is localized. */
90
+ function collectTextFields(fields, prefix, out) {
91
+ let localized = false;
92
+ for (const field of fields) {
93
+ const path = field.name ? `${prefix}${field.name}` : undefined;
94
+ if (path && (field.type === "text" || field.type === "textarea")) {
95
+ out.push({ path, kind: "text" });
96
+ localized ||= Boolean(field.localized);
97
+ }
98
+ else if (path && field.type === "richText") {
99
+ out.push({ path, kind: "richText" });
100
+ localized ||= Boolean(field.localized);
101
+ }
102
+ else if (field.type === "tabs") {
103
+ localized = collectTextFields(field.tabs ?? [], prefix, out) || localized;
104
+ }
105
+ else if (field.fields && field.type !== "blocks") {
106
+ // Groups, arrays and named tabs add a segment; rows, collapsibles and unnamed tabs do not.
107
+ // A localized group or array localizes the text fields inside it.
108
+ const before = out.length;
109
+ const nested = collectTextFields(field.fields, path ? `${path}.` : prefix, out);
110
+ localized ||= nested || (Boolean(field.localized) && out.length > before);
111
+ }
112
+ }
113
+ return localized;
114
+ }
@@ -0,0 +1,27 @@
1
+ import { type CheckId, type Finding } from "./types.ts";
2
+ export type DocumentFindings = {
3
+ collection: string;
4
+ id: string | number;
5
+ title: string;
6
+ /** One per locale for localized fields, otherwise one. */
7
+ findings: Finding[];
8
+ };
9
+ export type CheckGroup = {
10
+ check: CheckId;
11
+ documents: DocumentFindings[];
12
+ };
13
+ /** Findings per check, in `CHECK_IDS` order, with a document's locales collected on one row. */
14
+ export declare function groupFindings(findings: Finding[]): CheckGroup[];
15
+ /** Distinct documents with at least one finding. */
16
+ export declare function countDocuments(findings: Finding[]): number;
17
+ export type CheckStats = {
18
+ check: CheckId;
19
+ /** Field values the check applied to. */
20
+ checked: number;
21
+ /** Values with a finding. */
22
+ failing: number;
23
+ /** Documents with a finding, locales collected on one row. */
24
+ documents: DocumentFindings[];
25
+ };
26
+ /** Per check that ran: how many values it looked at and which ones need work. */
27
+ export declare function checkStats(checks: CheckId[], checked: Partial<Record<CheckId, number>>, findings: Finding[]): CheckStats[];
package/dist/group.js ADDED
@@ -0,0 +1,38 @@
1
+ import { CHECK_IDS } from "./types.js";
2
+ /** Findings per check, in `CHECK_IDS` order, with a document's locales collected on one row. */
3
+ export function groupFindings(findings) {
4
+ const groups = new Map();
5
+ for (const finding of findings) {
6
+ let documents = groups.get(finding.check);
7
+ if (!documents) {
8
+ documents = new Map();
9
+ groups.set(finding.check, documents);
10
+ }
11
+ const key = `${finding.collection}:${finding.id}`;
12
+ const existing = documents.get(key);
13
+ if (existing) {
14
+ existing.findings.push(finding);
15
+ }
16
+ else {
17
+ const { collection, id, title } = finding;
18
+ documents.set(key, { collection, id, title, findings: [finding] });
19
+ }
20
+ }
21
+ return CHECK_IDS.flatMap((check) => {
22
+ const documents = groups.get(check);
23
+ return documents ? [{ check, documents: [...documents.values()] }] : [];
24
+ });
25
+ }
26
+ /** Distinct documents with at least one finding. */
27
+ export function countDocuments(findings) {
28
+ return new Set(findings.map((finding) => `${finding.collection}:${finding.id}`)).size;
29
+ }
30
+ /** Per check that ran: how many values it looked at and which ones need work. */
31
+ export function checkStats(checks, checked, findings) {
32
+ const groups = new Map(groupFindings(findings).map((group) => [group.check, group.documents]));
33
+ return checks.map((check) => {
34
+ const documents = groups.get(check) ?? [];
35
+ const failing = documents.reduce((sum, row) => sum + row.findings.length, 0);
36
+ return { check, checked: checked[check] ?? 0, failing, documents };
37
+ });
38
+ }
@@ -0,0 +1,11 @@
1
+ export type { DocumentInput, DocumentResult, FieldRule, ResolvedRule } from "./checks.ts";
2
+ export { checkDocument, DEFAULT_MIN_WORDS, rulesFor, SEO_DEFAULTS } from "./checks.ts";
3
+ export type { Heading, PageContent } from "./content.ts";
4
+ export { countWords, readContent } from "./content.ts";
5
+ export type { CheckGroup, CheckStats, DocumentFindings } from "./group.ts";
6
+ export { checkStats, countDocuments, groupFindings } from "./group.ts";
7
+ export { contentHealthPlugin, WIDGET_SLUG } from "./plugin.ts";
8
+ export type { ScanArgs, ScanResult } from "./scan.ts";
9
+ export { scanContentHealth } from "./scan.ts";
10
+ export type { AltTextOptions, CheckId, CollectionHealthOptions, ContentHealthPluginOptions, ContentOptions, Finding, HeadingLevels, LengthRange, SeoOptions, } from "./types.ts";
11
+ export { CHECK_IDS } from "./types.ts";
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { checkDocument, DEFAULT_MIN_WORDS, rulesFor, SEO_DEFAULTS } from "./checks.js";
2
+ export { countWords, readContent } from "./content.js";
3
+ export { checkStats, countDocuments, groupFindings } from "./group.js";
4
+ export { contentHealthPlugin, WIDGET_SLUG } from "./plugin.js";
5
+ export { scanContentHealth } from "./scan.js";
6
+ export { CHECK_IDS } from "./types.js";
@@ -0,0 +1,2 @@
1
+ export declare const PACKAGE_NAME = "@simmalugnt-se/payload-content-health";
2
+ export declare const RSC_ENTRY = "@simmalugnt-se/payload-content-health/rsc";
@@ -0,0 +1,2 @@
1
+ export const PACKAGE_NAME = "@simmalugnt-se/payload-content-health";
2
+ export const RSC_ENTRY = `${PACKAGE_NAME}/rsc`;
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from "payload";
2
+ import { type ContentHealthPluginOptions } from "./types.ts";
3
+ export declare const WIDGET_SLUG = "content-health";
4
+ export declare function contentHealthPlugin(options: ContentHealthPluginOptions): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,51 @@
1
+ import { CHECK_LABELS } from "./copy.js";
2
+ import { RSC_ENTRY } from "./package-name.js";
3
+ import { CUSTOM_KEY } from "./scan.js";
4
+ import { CHECK_IDS } from "./types.js";
5
+ export const WIDGET_SLUG = "content-health";
6
+ const widget = {
7
+ slug: WIDGET_SLUG,
8
+ Component: `${RSC_ENTRY}#ContentHealthWidget`,
9
+ label: { en: "Content health", sv: "Innehållshälsa" },
10
+ minWidth: "medium",
11
+ fields: [
12
+ {
13
+ name: "checks",
14
+ type: "select",
15
+ hasMany: true,
16
+ label: { en: "Checks (all when empty)", sv: "Kontroller (alla om tomt)" },
17
+ options: CHECK_IDS.map((id) => ({
18
+ value: id,
19
+ label: { en: CHECK_LABELS.en[id], sv: CHECK_LABELS.sv[id] },
20
+ })),
21
+ },
22
+ ],
23
+ };
24
+ export function contentHealthPlugin(options) {
25
+ return (config) => {
26
+ if (options.disabled) {
27
+ return config;
28
+ }
29
+ const dashboard = config.admin?.dashboard;
30
+ // Payload only adds its collection cards to the default layout when none is set, so keep them.
31
+ const defaultLayout = dashboard?.defaultLayout ??
32
+ (options.addToDefaultLayout === false
33
+ ? undefined
34
+ : [
35
+ { widgetSlug: "collections", width: "full" },
36
+ { widgetSlug: WIDGET_SLUG, width: "full" },
37
+ ]);
38
+ return {
39
+ ...config,
40
+ admin: {
41
+ ...config.admin,
42
+ dashboard: {
43
+ ...dashboard,
44
+ widgets: [...(dashboard?.widgets ?? []), widget],
45
+ ...(defaultLayout ? { defaultLayout } : {}),
46
+ },
47
+ },
48
+ custom: { ...config.custom, [CUSTOM_KEY]: options },
49
+ };
50
+ };
51
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { Payload, SanitizedConfig, TypedUser } from "payload";
2
+ import { type CheckId, type ContentHealthPluginOptions, type Finding } from "./types.ts";
3
+ /** Where the plugin keeps its options in the (server-only) `config.custom`. */
4
+ export declare const CUSTOM_KEY = "contentHealth";
5
+ export declare function contentHealthOptions(config: SanitizedConfig): ContentHealthPluginOptions | undefined;
6
+ export type ScanResult = {
7
+ findings: Finding[];
8
+ /** The checks that ran, in `CHECK_IDS` order. */
9
+ checks: CheckId[];
10
+ /** Field values each check applied to; `checked - findings` of a check are in order. */
11
+ checked: Partial<Record<CheckId, number>>;
12
+ /** Documents read per collection. */
13
+ documents: Record<string, number>;
14
+ /** Configured checks that could not run, e.g. a field path that does not exist. */
15
+ problems: string[];
16
+ };
17
+ export type ScanArgs = {
18
+ payload: Payload;
19
+ /** Documents are read with this user's access. */
20
+ user: TypedUser | null;
21
+ /** Only these checks; all configured ones when unset or empty. */
22
+ checks?: CheckId[];
23
+ };
24
+ /**
25
+ * Runs the configured checks on the latest version (draft included) of every document the user can
26
+ * read, once per locale for localized fields.
27
+ */
28
+ export declare function scanContentHealth({ payload, user, checks }: ScanArgs): Promise<ScanResult>;
package/dist/scan.js ADDED
@@ -0,0 +1,124 @@
1
+ import { checkDocument, documentTitle, rulesFor } from "./checks.js";
2
+ import { contentSchema, lookupPath, mergeSelect, } from "./fields.js";
3
+ import { CHECK_IDS } from "./types.js";
4
+ /** Where the plugin keeps its options in the (server-only) `config.custom`. */
5
+ export const CUSTOM_KEY = "contentHealth";
6
+ export function contentHealthOptions(config) {
7
+ return config.custom?.[CUSTOM_KEY];
8
+ }
9
+ /**
10
+ * Runs the configured checks on the latest version (draft included) of every document the user can
11
+ * read, once per locale for localized fields.
12
+ */
13
+ export async function scanContentHealth({ payload, user, checks }) {
14
+ const result = {
15
+ findings: [],
16
+ checks: [],
17
+ checked: {},
18
+ documents: {},
19
+ problems: [],
20
+ };
21
+ const ran = new Set();
22
+ const options = contentHealthOptions(payload.config);
23
+ if (!options) {
24
+ result.problems.push("contentHealthPlugin is not installed.");
25
+ return result;
26
+ }
27
+ const blocks = (payload.config.blocks ?? []);
28
+ const resolveBlock = (slug) => blocks.find((block) => block.slug === slug);
29
+ const locales = payload.config.localization
30
+ ? payload.config.localization.localeCodes
31
+ : [undefined];
32
+ for (const [slug, collectionOptions] of Object.entries(options.collections)) {
33
+ const collection = payload.collections[slug]?.config;
34
+ if (!collection) {
35
+ result.problems.push(`Collection "${slug}" does not exist.`);
36
+ continue;
37
+ }
38
+ const fields = collection.fields;
39
+ const select = {};
40
+ const rules = [];
41
+ for (const rule of rulesFor(collectionOptions)) {
42
+ if (checks?.length && !checks.includes(rule.check)) {
43
+ continue;
44
+ }
45
+ const info = lookupPath(fields, rule.path);
46
+ if (!info) {
47
+ result.problems.push(`${slug}: no field at "${rule.path}" for ${rule.check}.`);
48
+ continue;
49
+ }
50
+ let localized = info.localized;
51
+ let content;
52
+ if (rule.kind === "outline" || rule.kind === "words") {
53
+ const body = contentSchema(info.field, resolveBlock);
54
+ if (!body) {
55
+ result.problems.push(`${slug}: "${rule.path}" is not a blocks or rich text field.`);
56
+ continue;
57
+ }
58
+ content = body.schema;
59
+ localized ||= body.localized;
60
+ }
61
+ ran.add(rule.check);
62
+ rules.push({
63
+ ...rule,
64
+ localized: Boolean(payload.config.localization) && localized,
65
+ ...(content ? { content } : {}),
66
+ });
67
+ mergeSelect(select, info.select);
68
+ }
69
+ if (!rules.length) {
70
+ continue;
71
+ }
72
+ const useAsTitle = collection.admin?.useAsTitle;
73
+ if (useAsTitle && useAsTitle !== "id") {
74
+ mergeSelect(select, { [useAsTitle]: true });
75
+ }
76
+ if (collection.upload) {
77
+ mergeSelect(select, { filename: true, mimeType: true });
78
+ }
79
+ const drafts = Boolean(collection.versions && collection.versions.drafts);
80
+ if (drafts) {
81
+ mergeSelect(select, { _status: true });
82
+ }
83
+ const anyLocalized = rules.some((rule) => rule.localized);
84
+ for (const [index, locale] of locales.entries()) {
85
+ if (index > 0 && !anyLocalized) {
86
+ break;
87
+ }
88
+ try {
89
+ const { docs } = await payload.find({
90
+ collection: slug,
91
+ depth: 0,
92
+ draft: drafts,
93
+ fallbackLocale: false,
94
+ ...(locale ? { locale } : {}),
95
+ overrideAccess: false,
96
+ pagination: false,
97
+ select: select,
98
+ user,
99
+ });
100
+ result.documents[slug] = docs.length;
101
+ for (const doc of docs) {
102
+ const { findings, checked } = checkDocument({
103
+ collection: slug,
104
+ doc,
105
+ rules,
106
+ title: documentTitle(doc, useAsTitle),
107
+ ...(locale ? { locale } : {}),
108
+ includeUnlocalized: index === 0,
109
+ });
110
+ result.findings.push(...findings);
111
+ for (const check of checked) {
112
+ result.checked[check] = (result.checked[check] ?? 0) + 1;
113
+ }
114
+ }
115
+ }
116
+ catch (error) {
117
+ result.problems.push(`${slug}: ${error instanceof Error ? error.message : String(error)}`);
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ result.checks = CHECK_IDS.filter((check) => ran.has(check));
123
+ return result;
124
+ }
@@ -0,0 +1,173 @@
1
+ /* Laid out like Payload's collection cards: a section heading, then the content. */
2
+ /* The accent is the blue the editor assistant and visual editing use. */
3
+ .content-health {
4
+ --content-health-accent: #2563eb;
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: var(--base);
8
+ width: 100%;
9
+ /* Payload spaces widgets 12px apart; its collection groups sit one --base apart. */
10
+ padding-top: calc(var(--base) - 12px);
11
+ }
12
+
13
+ .content-health__heading {
14
+ margin: 0;
15
+ }
16
+
17
+ .content-health__panel {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: calc(var(--base) * 1.25);
21
+ padding: calc(var(--base) * 1.25);
22
+ border: 1px solid var(--theme-elevation-150);
23
+ border-radius: var(--style-radius-m);
24
+ background: var(--theme-elevation-0);
25
+ }
26
+
27
+ .content-health__overview {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: calc(var(--base) * 1.25);
31
+ }
32
+
33
+ .content-health__overall {
34
+ margin: 0;
35
+ font-size: 1.15rem;
36
+ font-weight: 500;
37
+ }
38
+
39
+ .content-health__summary {
40
+ margin: calc(var(--base) * 0.25) 0 0;
41
+ color: var(--theme-elevation-600);
42
+ }
43
+
44
+ /* Rings */
45
+ .content-health__ring {
46
+ flex: none;
47
+ width: 3.75rem;
48
+ height: 3.75rem;
49
+ }
50
+
51
+ .content-health__ring--large {
52
+ width: 7.5rem;
53
+ height: 7.5rem;
54
+ }
55
+
56
+ .content-health__ring-track {
57
+ stroke: var(--theme-elevation-150);
58
+ }
59
+
60
+ .content-health__ring-ok {
61
+ stroke: var(--content-health-accent);
62
+ }
63
+
64
+ html[data-theme="dark"] .content-health {
65
+ --content-health-accent: #60a5fa;
66
+ }
67
+
68
+ .content-health__ring-value {
69
+ fill: var(--theme-text);
70
+ /* viewBox units: scales with the ring. */
71
+ font-size: 24px;
72
+ font-weight: 600;
73
+ font-variant-numeric: tabular-nums;
74
+ }
75
+
76
+ /* One tile per check, as many columns as fit. */
77
+ .content-health__tiles {
78
+ display: grid;
79
+ grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
80
+ gap: 12px;
81
+ margin: 0;
82
+ padding: 0;
83
+ list-style: none;
84
+ }
85
+
86
+ .content-health__tile {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: calc(var(--base) * 0.75);
90
+ padding: var(--base);
91
+ /* Same as Payload's collection cards. */
92
+ border: 1px solid var(--theme-elevation-150);
93
+ border-radius: var(--style-radius-m);
94
+ background: var(--theme-elevation-50);
95
+ }
96
+
97
+ .content-health__tile-head {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: calc(var(--base) * 0.75);
101
+ }
102
+
103
+ .content-health__tile-title {
104
+ margin: 0;
105
+ font-size: 1rem;
106
+ line-height: 1.3;
107
+ }
108
+
109
+ .content-health__tile-status {
110
+ margin: calc(var(--base) * 0.15) 0 0;
111
+ color: var(--theme-elevation-600);
112
+ font-variant-numeric: tabular-nums;
113
+ }
114
+
115
+ .content-health__rows {
116
+ display: flex;
117
+ flex-direction: column;
118
+ gap: calc(var(--base) * 0.3);
119
+ margin: 0;
120
+ padding: calc(var(--base) * 0.6) 0 0;
121
+ border-top: 1px solid var(--theme-elevation-100);
122
+ list-style: none;
123
+ }
124
+
125
+ .content-health__row {
126
+ display: flex;
127
+ flex-wrap: wrap;
128
+ align-items: baseline;
129
+ gap: calc(var(--base) * 0.3);
130
+ }
131
+
132
+ .content-health__locale {
133
+ display: inline-flex;
134
+ align-items: baseline;
135
+ gap: calc(var(--base) * 0.2);
136
+ }
137
+
138
+ .content-health__pill {
139
+ padding: 0 calc(var(--base) * 0.3);
140
+ border-radius: var(--style-radius-s);
141
+ background: var(--theme-elevation-150);
142
+ color: var(--theme-elevation-800);
143
+ font-size: 0.75em;
144
+ text-decoration: none;
145
+ text-transform: uppercase;
146
+ }
147
+
148
+ .content-health__pill:hover {
149
+ background: var(--theme-elevation-250);
150
+ }
151
+
152
+ .content-health__detail,
153
+ .content-health__more {
154
+ color: var(--theme-elevation-500);
155
+ font-size: 0.9em;
156
+ }
157
+
158
+ .content-health__problems {
159
+ color: var(--theme-error-600);
160
+ font-size: 0.9em;
161
+ }
162
+
163
+ .content-health__problems p,
164
+ .content-health__problems ul {
165
+ margin: 0;
166
+ }
167
+
168
+ @media (max-width: 768px) {
169
+ .content-health__ring--large {
170
+ width: 5.5rem;
171
+ height: 5.5rem;
172
+ }
173
+ }