From f0820a4fcad7a2f5356e44db483d36a9506a526f Mon Sep 17 00:00:00 2001 From: Albin David C Date: Thu, 19 Mar 2026 19:02:03 +0530 Subject: [PATCH 1/7] feat(tags): create tags in the quotes schema --- packages/schemas/src/quotes.ts | 37 +++++++++++++++++++++++++++++++++- packages/schemas/tsconfig.json | 3 ++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/schemas/src/quotes.ts b/packages/schemas/src/quotes.ts index 2925e56a5019..693b111e386a 100644 --- a/packages/schemas/src/quotes.ts +++ b/packages/schemas/src/quotes.ts @@ -2,6 +2,23 @@ import { z } from "zod"; import { IdSchema } from "./util"; import { LanguageSchema } from "./languages"; +// Tags for quotes +export const QUOTE_TAGS = [ + "fiction", + "poetry", + "philosophy", + "political", + "inspirational", + "wisdom", + "mindset", + "humorous", +] as const; +export type QuoteTag = (typeof QUOTE_TAGS)[number]; + +// Tagged Languages +export const TAGGED_LANGUAGES = ["english"] as const; +export type TaggedLanguage = (typeof TAGGED_LANGUAGES)[number]; + export const QuoteIdSchema = z .number() .int() @@ -26,6 +43,9 @@ export const QuoteSchema = z.object({ submittedBy: IdSchema.describe("uid of the submitter"), timestamp: z.number().int().nonnegative(), approved: z.boolean(), + + //Remove '.optional()' once all languages are tagged + tags: z.array(z.enum(QUOTE_TAGS)).min(1).optional(), }); export type Quote = z.infer; @@ -56,11 +76,12 @@ export const QuoteDataQuoteSchema = z source: z.string(), length: z.number(), approvedBy: z.string().optional(), + tags: z.array(z.enum(QUOTE_TAGS)).min(1).optional(), }) .strict(); export type QuoteDataQuote = z.infer; -export const QuoteDataSchema = z +const QuoteDataBaseSchema = z .object({ language: LanguageSchema, groups: z.array(z.tuple([z.number(), z.number()])).length(4), @@ -68,4 +89,18 @@ export const QuoteDataSchema = z }) .strict(); +export const QuoteDataSchema = QuoteDataBaseSchema.superRefine((data, ctx) => { + if ((TAGGED_LANGUAGES as readonly string[]).includes(data.language)) { + data.quotes.forEach((quote, index) => { + if (!quote.tags || quote.tags.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Quote at index ${index} must have at least one tag.`, + path: ["quotes", index, "tags"], + }); + } + }); + } +}); + export type QuoteData = z.infer; diff --git a/packages/schemas/tsconfig.json b/packages/schemas/tsconfig.json index 0ccb62588644..fc7b754ce213 100644 --- a/packages/schemas/tsconfig.json +++ b/packages/schemas/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", - "target": "ES6" + "target": "ES6", + "lib": ["ESNext"] }, "include": ["src"], "exclude": ["node_modules", "dist"] From 0fde181833f5a4e8c82801a0b0985bf2da428a11 Mon Sep 17 00:00:00 2001 From: Albin David C Date: Thu, 19 Mar 2026 19:22:33 +0530 Subject: [PATCH 2/7] feat(tags): create tags in the test template --- frontend/src/html/pages/test.html | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index fa9ed4bd9913..505d05a13f80 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -73,6 +73,47 @@ + +
Date: Thu, 19 Mar 2026 19:32:13 +0530 Subject: [PATCH 3/7] feat(tags): create styles of tags in test file --- frontend/src/styles/test.scss | 150 ++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) diff --git a/frontend/src/styles/test.scss b/frontend/src/styles/test.scss index daa6e532be96..a3ae4213b7e8 100644 --- a/frontend/src/styles/test.scss +++ b/frontend/src/styles/test.scss @@ -1662,3 +1662,153 @@ body.fb-arrows { } } } + +// Quote Tag Filter +#testConfig { + // Register .quoteTagFilter alongside the other config sections so it gets + // the same grid-auto-flow and textButton padding treatment. + .quoteTagFilter { + display: grid; + grid-auto-flow: column; + justify-content: end; + position: relative; // anchor for the absolute dropdown + + .textButton { + padding: var(--verticalPadding) var(--horizontalPadding); + + &:first-child { + margin-left: var(--horizontalPadding); + } + &:last-child { + margin-right: var(--horizontalPadding); + } + } + } +} + +// Trigger button extras +.quoteTagFilterTrigger { + display: flex; + align-items: center; + gap: 0.35em; + white-space: nowrap; +} + +.quoteTagFilterChevron { + font-size: 0.7em; + opacity: 0.5; + transition: + transform 0.2s ease, + opacity 0.2s ease; +} + +.quoteTagFilterTrigger[aria-expanded="true"] .quoteTagFilterChevron { + transform: rotate(180deg); + opacity: 1; +} + +// Dropdown panel +.quoteTagFilterDropdown { + position: absolute; + // Sit just below the config row; z-index keeps it above #words + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(-4px); + z-index: 200; + + min-width: 240px; + padding: 0.6rem; + border-radius: var(--roundness); + + background-color: var(--sub-alt-color); + border: 1px solid color-mix(in srgb, var(--sub-color) 40%, transparent); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); + + // Hidden by default — .open class toggled by JS + opacity: 0; + pointer-events: none; + transition: + opacity 0.15s ease, + transform 0.15s ease; + + &.open { + opacity: 1; + pointer-events: all; + transform: translateX(-50%) translateY(0); + } +} + +// Dropdown header +.quoteTagFilterDropdownHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid color-mix(in srgb, var(--sub-color) 30%, transparent); +} + +.quoteTagFilterDropdownTitle { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sub-color); +} + +// Clear button — compact variant of .textButton +.quoteTagFilterClearBtn { + font-size: 0.7rem; + padding: 0.15em 0.5em; + color: var(--sub-color); + visibility: hidden; // shown by JS only when tags are selected + transition: color 0.125s; + + &:hover { + color: var(--text-color); + } +} + +// Tag pills +.quoteTagFilterPills { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +// Each pill reuses .textButton as a base class (applied in JS). +// Additional pill-specific overrides live here. +.quoteTagPill { + display: inline-flex; + align-items: center; + gap: 0.3em; + padding: 0.25em 0.65em; + border-radius: 999px; // pill shape + font-size: 0.75rem; + opacity: 0.5; + transition: + background-color 0.125s, + color 0.125s, + opacity 0.125s; + + i { + font-size: 0.7em; + } + + &:hover { + opacity: 0.85; + } + + // Active state mirrors MonkeyType's selected config button pattern + &.active { + opacity: 1; + color: var(--main-color); + background-color: color-mix(in srgb, var(--main-color) 12%, transparent); + } +} + +// Responsive +@media (max-width: 480px) { + .quoteTagFilterDropdown { + min-width: 190px; + } +} From bdca5d0adcc99c0a13905dec2bd8a1d8e00ad090 Mon Sep 17 00:00:00 2001 From: Albin David C Date: Thu, 19 Mar 2026 20:26:48 +0530 Subject: [PATCH 4/7] feat(tags): added logic and connecting the template file --- frontend/scripts/check-assets.ts | 4 +- .../ts/commandline/commandline-metadata.ts | 8 + frontend/src/ts/config/metadata.ts | 9 + frontend/src/ts/config/setters.ts | 20 ++ frontend/src/ts/constants/default-config.ts | 1 + frontend/src/ts/elements/quote-tag-filter.ts | 268 ++++++++++++++++++ frontend/src/ts/pages/test.ts | 5 + frontend/src/ts/test/test-config.ts | 6 + packages/schemas/src/configs.ts | 2 + packages/schemas/src/quotes.ts | 2 +- 10 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 frontend/src/ts/elements/quote-tag-filter.ts diff --git a/frontend/scripts/check-assets.ts b/frontend/scripts/check-assets.ts index f58b54d49d5c..7c0e655c9397 100644 --- a/frontend/scripts/check-assets.ts +++ b/frontend/scripts/check-assets.ts @@ -21,7 +21,7 @@ import { themes, ThemeSchema, ThemesList } from "../src/ts/constants/themes"; import { z } from "zod"; import { ChallengeSchema, Challenge } from "@monkeytype/schemas/challenges"; import { LayoutObject, LayoutObjectSchema } from "@monkeytype/schemas/layouts"; -import { QuoteDataSchema, QuoteData } from "@monkeytype/schemas/quotes"; +import { QuoteDataBaseSchema, QuoteData } from "@monkeytype/schemas/quotes"; class Problems { private type: string; @@ -185,7 +185,7 @@ async function validateQuotes(): Promise { } //check schema - const schema = QuoteDataSchema.extend({ + const schema = QuoteDataBaseSchema.extend({ language: LanguageSchema //icelandic only exists as icelandic_1k, language in quote file is stripped of its size .or(z.literal("icelandic")), diff --git a/frontend/src/ts/commandline/commandline-metadata.ts b/frontend/src/ts/commandline/commandline-metadata.ts index 9744c9ee16ab..e695b362b20d 100644 --- a/frontend/src/ts/commandline/commandline-metadata.ts +++ b/frontend/src/ts/commandline/commandline-metadata.ts @@ -183,6 +183,14 @@ export const commandlineConfigMetadata: CommandlineConfigMetadataObject = { }, }, }, + quoteTags: { + subgroup: { + options: "fromSchema", + afterExec: () => { + TestLogic.restart(); + }, + }, + }, //behavior difficulty: { subgroup: { diff --git a/frontend/src/ts/config/metadata.ts b/frontend/src/ts/config/metadata.ts index 4a5f62f5c2a6..fd819fbf124f 100644 --- a/frontend/src/ts/config/metadata.ts +++ b/frontend/src/ts/config/metadata.ts @@ -179,6 +179,15 @@ export const configMetadata: ConfigMetadataObject = { return {}; }, }, + quoteTags: { + icon: "fa-tags", + displayString: "quote tags", + changeRequiresRestart: false, + group: "test", + overrideValue: ({ value }) => { + return [...new Set(value)]; + }, + }, language: { icon: "fa-language", displayString: "language", diff --git a/frontend/src/ts/config/setters.ts b/frontend/src/ts/config/setters.ts index 385b948fc428..456ac6156196 100644 --- a/frontend/src/ts/config/setters.ts +++ b/frontend/src/ts/config/setters.ts @@ -14,6 +14,26 @@ import { typedKeys, triggerResize, escapeHTML } from "../utils/misc"; import { camelCaseToWords, capitalizeFirstLetter } from "../utils/strings"; import { Config, setConfigStore } from "./store"; import { FunboxName } from "@monkeytype/schemas/configs"; +import { type QuoteTag } from "@monkeytype/schemas/quotes"; + +export function setQuoteTags(tags: QuoteTag[], nosave?: boolean): boolean { + return setConfig("quoteTags", tags, { + nosave, + }); +} + +export function toggleQuoteTag(tag: QuoteTag, nosave?: boolean): void { + const current = [...Config.quoteTags]; + const idx = current.indexOf(tag); + + if (idx === -1) { + current.push(tag); + } else { + current.splice(idx, 1); + } + + setQuoteTags(current, nosave); +} export function setConfig( key: T, diff --git a/frontend/src/ts/constants/default-config.ts b/frontend/src/ts/constants/default-config.ts index c2fea2f0d058..b10a4a84cd13 100644 --- a/frontend/src/ts/constants/default-config.ts +++ b/frontend/src/ts/constants/default-config.ts @@ -29,6 +29,7 @@ const obj: Config = { time: 30, mode: "time", quoteLength: [1], + quoteTags: [], language: "english", fontSize: 2, freedomMode: false, diff --git a/frontend/src/ts/elements/quote-tag-filter.ts b/frontend/src/ts/elements/quote-tag-filter.ts new file mode 100644 index 000000000000..ab272256f3d1 --- /dev/null +++ b/frontend/src/ts/elements/quote-tag-filter.ts @@ -0,0 +1,268 @@ +import { Config } from "../config/store"; +import { toggleQuoteTag, setQuoteTags } from "../config/setters"; +import { QUOTE_TAGS, type QuoteTag } from "@monkeytype/schemas/quotes"; + +const TAG_LABELS: Record = { + fiction: "Fiction", + poetry: "Poetry", + philosophy: "Philosophy", + political: "Political", + inspirational: "Inspirational", + wisdom: "Wisdom", + mindset: "Mindset", + humorous: "Humorous", +}; + +const TAG_ICONS: Record = { + fiction: "fa-book-open", + poetry: "fa-feather-alt", + philosophy: "fa-brain", + political: "fa-landmark", + inspirational: "fa-bolt", + wisdom: "fa-scroll", + mindset: "fa-seedling", + humorous: "fa-laugh", +}; + +let initialized = false; +let pillsBuilt = false; +let isOpen = false; + +// DOM references + +function getWrapper(): HTMLElement | null { + return document.querySelector(".quoteTagFilter"); +} + +function getTrigger(): HTMLButtonElement | null { + return document.getElementById( + "quoteTagFilterTrigger", + ) as HTMLButtonElement | null; +} + +function getDropdown(): HTMLElement | null { + return document.getElementById("quoteTagFilterDropdown"); +} + +function getPillsContainer(): HTMLElement | null { + return document.getElementById("quoteTagFilterPills"); +} + +function getClearBtn(): HTMLButtonElement | null { + return document.getElementById( + "quoteTagFilterClearBtn", + ) as HTMLButtonElement | null; +} + +// Open / close + +function open(): void { + if (isOpen) return; + + if (!pillsBuilt) { + buildPills(); + pillsBuilt = true; + } + + isOpen = true; + + const trigger = getTrigger(); + const dropdown = getDropdown(); + trigger?.setAttribute("aria-expanded", "true"); + trigger?.classList.add("active"); + dropdown?.classList.add("open"); + + // Close when the user clicks outside the dropdown + setTimeout(() => { + document.addEventListener("click", handleOutsideClick); + document.addEventListener("keydown", handleEscape); + }, 0); +} + +function close(): void { + if (!isOpen) return; + isOpen = false; + + const trigger = getTrigger(); + const dropdown = getDropdown(); + trigger?.setAttribute("aria-expanded", "false"); + trigger?.classList.remove("active"); + dropdown?.classList.remove("open"); + + document.removeEventListener("click", handleOutsideClick); + document.removeEventListener("keydown", handleEscape); +} + +function toggle(): void { + isOpen ? close() : open(); +} + +function handleOutsideClick(e: MouseEvent): void { + const wrapper = getWrapper(); + if (wrapper && !wrapper.contains(e.target as Node)) { + close(); + } +} + +function handleEscape(e: KeyboardEvent): void { + if (e.key === "Escape") { + close(); + getTrigger()?.focus(); + } +} + +// Pill builder + +function buildPills(): void { + const container = getPillsContainer(); + if (!container) return; + + container.innerHTML = ""; + const active = new Set(Config.quoteTags); + + for (const tag of QUOTE_TAGS) { + container.appendChild(createPill(tag, active.has(tag))); + } +} + +function createPill(tag: QuoteTag, active: boolean): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `textButton quoteTagPill${active ? " active" : ""}`; + btn.dataset["tag"] = tag; + btn.setAttribute("aria-pressed", String(active)); + btn.title = TAG_LABELS[tag]; + + btn.innerHTML = ` + + ${TAG_LABELS[tag]} + `; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); // don't bubble to the outside-click handler + toggleQuoteTag(tag); + syncPills(); + syncLabel(); + syncClearBtn(); + }); + + return btn; +} + +// Sync helpers (keep DOM in step with Config) + +function syncPills(): void { + const container = getPillsContainer(); + if (!container) return; + + const active = new Set(Config.quoteTags); + + container + .querySelectorAll(".quoteTagPill") + .forEach((btn) => { + const tag = btn.dataset["tag"] as QuoteTag | undefined; + if (!tag) return; + const on = active.has(tag); + btn.classList.toggle("active", on); + btn.setAttribute("aria-pressed", String(on)); + }); +} + +/** + * Updates the trigger button label. Shows "all tags" when nothing is selected, or a comma list up to 2 tags followed by " +N" when there are more. + */ +function syncLabel(): void { + const label = getTrigger()?.querySelector( + ".quoteTagFilterLabel", + ); + if (!label) return; + + const tags = Config.quoteTags; + + if (tags.length === 0) { + label.textContent = "all tags"; + return; + } + + const shown = tags + .slice(0, 2) + .map((t) => TAG_LABELS[t]) + .join(", "); + const extra = tags.length > 2 ? ` +${tags.length - 2}` : ""; + label.textContent = shown + extra; +} + +function syncClearBtn(): void { + const btn = getClearBtn(); + if (!btn) return; + const hasTags = Config.quoteTags.length > 0; + btn.style.visibility = hasTags ? "visible" : "hidden"; + btn.setAttribute("aria-disabled", String(!hasTags)); +} + +// Public API + +/** + * Call this once after the DOM is ready to wire up the trigger and clear button event listeners. + */ +export function init(): void { + // Always reset pillsBuilt because the DOM might have been replaced + pillsBuilt = false; + isOpen = false; + + const trigger = getTrigger(); + const clearBtn = getClearBtn(); + + if (initialized) { + syncLabel(); + syncClearBtn(); + return; + } + + trigger?.addEventListener("click", (e) => { + e.stopPropagation(); + toggle(); + }); + + clearBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + setQuoteTags([]); + syncPills(); + syncLabel(); + syncClearBtn(); + }); + + syncLabel(); + syncClearBtn(); + initialized = true; +} + +/** + * Shows the filter block only in quote mode. + */ +export function setVisible(visible: boolean): void { + const wrapper = getWrapper(); + if (!wrapper) return; + + if (visible) { + wrapper.classList.remove("hidden"); + } else { + wrapper.classList.add("hidden"); + close(); + } +} + +/** + * Call this whenever Config.quoteTags changes externally so the dropdown stays in sync without needing to be open. + */ +export function update(): void { + pillsBuilt = false; + + if (isOpen) { + buildPills(); + pillsBuilt = true; + } + + syncLabel(); + syncClearBtn(); +} diff --git a/frontend/src/ts/pages/test.ts b/frontend/src/ts/pages/test.ts index fde5843701af..853364f7ff24 100644 --- a/frontend/src/ts/pages/test.ts +++ b/frontend/src/ts/pages/test.ts @@ -5,9 +5,11 @@ import Page from "./page"; import { updateFooterAndVerticalAds } from "../controllers/ad-controller"; import * as ModesNotice from "../elements/modes-notice"; import * as Keymap from "../elements/keymap"; +import * as QuoteTagFilter from "../elements/quote-tag-filter"; import * as TestConfig from "../test/test-config"; import { blurInputElement } from "../input/input-element"; import { qsr } from "../utils/dom"; +import { Config } from "../config/store"; export const page = new Page({ id: "test", @@ -32,5 +34,8 @@ export const page = new Page({ }); void TestConfig.instantUpdate(); void Keymap.refresh(); + + QuoteTagFilter.init(); + QuoteTagFilter.setVisible(Config.mode === "quote"); }, }); diff --git a/frontend/src/ts/test/test-config.ts b/frontend/src/ts/test/test-config.ts index ccfca5ef1552..f7a0ec00b7cb 100644 --- a/frontend/src/ts/test/test-config.ts +++ b/frontend/src/ts/test/test-config.ts @@ -7,6 +7,7 @@ import { applyReducedMotion } from "../utils/misc"; import { areUnsortedArraysEqual } from "../utils/arrays"; import { authEvent } from "../events/auth"; import { qs, qsa } from "../utils/dom"; +import * as QuoteTagFilter from "../elements/quote-tag-filter"; export function show(): void { qs("#testConfig")?.removeClass("invisible"); @@ -31,6 +32,7 @@ export async function instantUpdate(): Promise { qs("#testConfig .customText")?.hide(); qs("#testConfig .quoteLength")?.hide(); qs("#testConfig .zen")?.hide(); + QuoteTagFilter.setVisible(false); if (Config.mode === "time") { qs("#testConfig .puncAndNum")?.show()?.setStyle({ @@ -57,6 +59,8 @@ export async function instantUpdate(): Promise { qs("#testConfig .quoteLength")?.show(); updateActiveExtraButtons("quoteLength", Config.quoteLength); + + QuoteTagFilter.setVisible(true); } else if (Config.mode === "custom") { qs("#testConfig .puncAndNum")?.show()?.setStyle({ width: "", @@ -248,6 +252,8 @@ async function update(previous: Mode, current: Mode): Promise { }); currentEl?.setStyle({ width: "" }); + + QuoteTagFilter.setVisible(current === "quote"); } function updateActiveModeButtons(mode: Mode): void { diff --git a/packages/schemas/src/configs.ts b/packages/schemas/src/configs.ts index 77c257a54834..b48efff52f67 100644 --- a/packages/schemas/src/configs.ts +++ b/packages/schemas/src/configs.ts @@ -4,6 +4,7 @@ import * as Themes from "./themes"; import * as Layouts from "./layouts"; import { LanguageSchema } from "./languages"; import { FontNameSchema } from "./fonts"; +import { QUOTE_TAGS } from "./quotes"; export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]); export type SmoothCaret = z.infer; @@ -387,6 +388,7 @@ export const ConfigSchema = z time: TimeConfigSchema, mode: Shared.ModeSchema, quoteLength: QuoteLengthConfigSchema, + quoteTags: z.array(z.enum(QUOTE_TAGS)).default([]), language: LanguageSchema, burstHeatmap: z.boolean(), diff --git a/packages/schemas/src/quotes.ts b/packages/schemas/src/quotes.ts index 693b111e386a..6961e1bcdd4a 100644 --- a/packages/schemas/src/quotes.ts +++ b/packages/schemas/src/quotes.ts @@ -81,7 +81,7 @@ export const QuoteDataQuoteSchema = z .strict(); export type QuoteDataQuote = z.infer; -const QuoteDataBaseSchema = z +export const QuoteDataBaseSchema = z .object({ language: LanguageSchema, groups: z.array(z.tuple([z.number(), z.number()])).length(4), From f8b819092e39b504e48a6a7cecc5667ff5a091ab Mon Sep 17 00:00:00 2001 From: Albin David C Date: Fri, 20 Mar 2026 01:00:34 +0530 Subject: [PATCH 5/7] feat(tags): update tags and tags modal --- frontend/src/html/pages/test.html | 33 +- frontend/src/html/popups.html | 25 + frontend/src/styles/test.scss | 7 + frontend/src/ts/config/metadata.ts | 15 +- .../src/ts/controllers/quotes-controller.ts | 40 +- frontend/src/ts/elements/quote-tag-filter.ts | 215 +- frontend/src/ts/test/test-config.ts | 23 + frontend/src/ts/test/test-logic.ts | 2 + frontend/src/ts/test/words-generator.ts | 38 +- frontend/static/quotes/english.json | 19468 +++++++++++----- 10 files changed, 13251 insertions(+), 6615 deletions(-) diff --git a/frontend/src/html/pages/test.html b/frontend/src/html/pages/test.html index 505d05a13f80..058eac209357 100644 --- a/frontend/src/html/pages/test.html +++ b/frontend/src/html/pages/test.html @@ -73,45 +73,20 @@
+
+
diff --git a/frontend/src/html/popups.html b/frontend/src/html/popups.html index 10931f0aedcd..8257315a1fcc 100644 --- a/frontend/src/html/popups.html +++ b/frontend/src/html/popups.html @@ -1009,6 +1009,31 @@
+ + + +