diff --git a/frontend/.eslintignore b/frontend/.eslintignore deleted file mode 100644 index 6f654893e..000000000 --- a/frontend/.eslintignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -public -mockServiceWorker.js -/src/api/model.ts diff --git a/frontend/.eslintrc b/frontend/.eslintrc deleted file mode 100644 index 702e771df..000000000 --- a/frontend/.eslintrc +++ /dev/null @@ -1,35 +0,0 @@ -{ - "root": true, - "extends": [ - "plugin:vue/vue3-recommended", - "plugin:vuejs-accessibility/recommended", - "eslint:recommended", - "@vue/eslint-config-typescript", - "@vue/eslint-config-prettier/skip-formatting", - ], - "parserOptions": { - "ecmaVersion": "latest", - }, - "rules": { - "prettier/prettier": "warn", - "vuejs-accessibility/anchor-has-content": [ - "error", - { - "accessibleDirectives": ["tooltip"], - }, - ], - "vuejs-accessibility/label-has-for": [ - "error", - { - "controlComponents": ["AppInput"], - "required": { - "some": ["nesting", "id"], - }, - "allowChildren": true, - }, - ], - "vue/no-v-html": ["off"], - "vue/no-v-text-v-html-on-component": ["off"], - "vuejs-accessibility/mouse-events-have-key-events": ["off"], - }, -} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index da7fad0e0..df0e5598d 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -1,4 +1,7 @@ { + "[javascript][javascriptreact][typescript][typescriptreact][json][jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, "eslint.debug": true, "eslint.format.enable": true, "eslint.validate": ["vue", "javascript", "typescript"], diff --git a/frontend/bun.lockb b/frontend/bun.lockb deleted file mode 100755 index 144bbf0f8..000000000 Binary files a/frontend/bun.lockb and /dev/null differ diff --git a/frontend/e2e/axe.test.ts b/frontend/e2e/axe.test.ts index 5bb949ac5..ba1240302 100644 --- a/frontend/e2e/axe.test.ts +++ b/frontend/e2e/axe.test.ts @@ -43,12 +43,9 @@ const rules = [ { id: "region", selector: ":not([role='listbox']" }, ]; -type Test = Parameters[1]; - -/** generic page axe test */ -const checkPage = - (path: string, selector?: string): Test => - async ({ page, browserName }) => { +/** Reusable test for each page */ +const checkPage = (title: string, path: string, selector?: string) => + test(title, async ({ page, browserName }) => { test.skip(browserName !== "chromium", "Only test Axe on chromium"); /** navigate to page */ @@ -76,25 +73,27 @@ const checkPage = const violations = await getViolations(page); const violationsMessage = JSON.stringify(violations, null, 2); expect(violationsMessage).toBe("[]"); - }; + }); -/** check all pages */ -for (const path of paths) test("Accessibility check " + path, checkPage(path)); +for (const path of paths) checkPage("Accessibility check " + path, path); -/** extra testbed component tests */ -test( +checkPage( "Accessibility check /testbed (select single)", - checkPage("/testbed", ".select-single button"), + "/testbed", + ".select-single button", ); -test( +checkPage( "Accessibility check /testbed (select multi)", - checkPage("/testbed", ".select-multi button"), + "/testbed", + ".select-multi button", ); -test( +checkPage( "Accessibility check /testbed (select tags)", - checkPage("/testbed", ".select-tags input"), + "/testbed", + ".select-tags input", ); -test( +checkPage( "Accessibility check /testbed (select autocomplete)", - checkPage("/testbed", ".select-autocomplete input"), + "/testbed", + ".select-autocomplete input", ); diff --git a/frontend/e2e/node.test.ts b/frontend/e2e/node.test.ts index b3e0fa947..07f2e5ed4 100644 --- a/frontend/e2e/node.test.ts +++ b/frontend/e2e/node.test.ts @@ -277,8 +277,9 @@ test("Breadcrumbs section works", async ({ page, browserName }) => { "Has Phenotype", "Genu recurvatum", ]; - for (let n = 0; n < chain.length; n += 2) - await page.getByText(new RegExp(chain[n], "i")).first().click(); + for (let n = 0; n < chain.length; n += 2) { + await page.getByText(new RegExp(chain[n] || "", "i")).first().click(); + } /** util func to get inner text of breadcrumbs */ const checkBreadcrumbs = async (expectedArray: string[]) => { @@ -286,7 +287,7 @@ test("Breadcrumbs section works", async ({ page, browserName }) => { .locator("#breadcrumbs ~ .flex > *") .allInnerTexts(); for (const [index, actual] of Object.entries(items)) { - const expected = expectedArray[Number(index)]; + const expected = expectedArray[Number(index)] || ""; await expect(actual).toMatch(new RegExp(expected, "i")); } }; diff --git a/frontend/e2e/sectionHierarchy.ts b/frontend/e2e/sectionHierarchy.ts index 2c4a71003..3a7179234 100644 --- a/frontend/e2e/sectionHierarchy.ts +++ b/frontend/e2e/sectionHierarchy.ts @@ -93,9 +93,9 @@ describe("TocHier.vue", () => { // Parent rows exist and use the truncation class (.row-text). const parents = wrapper.findAll(".parents .parent-row .row-text"); expect(parents.length).toBe(2); - expect(parents[0].classes()).toContain("row-text"); + expect(parents[0]!.classes()).toContain("row-text"); // We can’t assert actual CSS truncation, but text presence confirms binding. - expect(parents[0].text()).toContain("A very very very long parent name"); + expect(parents[0]!.text()).toContain("A very very very long parent name"); }); it("renders current row with truncation", () => { diff --git a/frontend/e2e/text-annotator.test.ts b/frontend/e2e/text-annotator.test.ts index 66da20784..53cdb2c70 100644 --- a/frontend/e2e/text-annotator.test.ts +++ b/frontend/e2e/text-annotator.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; /** https://github.com/microsoft/playwright/issues/23662 */ -import example from "@/data/text-annotator.json"; +import example from "@/data/text-annotator.json" with { type: "json" }; import { log } from "../playwright.config"; log(); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 000000000..209b3875e --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,88 @@ +import url from "url"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import pluginVue from "eslint-plugin-vue"; +import { FlatCompat } from "@eslint/eslintrc"; +import js from "@eslint/js"; +import prettierConfig from "@vue/eslint-config-prettier"; +import { + defineConfigWithVueTs, + vueTsConfigs, +} from "@vue/eslint-config-typescript"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, +}); + +export default defineConfigWithVueTs( + compat.extends( + "plugin:vuejs-accessibility/recommended", + "eslint:recommended", + ), + eslintPluginPrettierRecommended, + pluginVue.configs["flat/recommended"], + prettierConfig, + vueTsConfigs.recommended, + { + ignores: [ + "node_modules", + "dist", + "public/duckdb/duckdb-browser-mvp.worker.js", + "public/mockServiceWorker.js", + "src/api/model.ts", + ], + }, + /** Additional Configuration */ + { + languageOptions: { + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + }, + files: [ + "**/*.js", + "**/*.cjs", + "**/*.mjs", + "**/*.ts", + "**/*.tsx", + "**/*.vue", + ], + /** Override rules */ + rules: { + "max-len": ["warn", { code: 120 }], + "no-constant-binary-expression": ["off"], + "prefer-const": "off", + "@typescript-eslint/no-empty-object-type": ["warn"], + "@typescript-eslint/no-explicit-any": ["warn"], + "@typescript-eslint/no-unused-expressions": ["off"], + "@typescript-eslint/no-unused-vars": ["off"], + "prettier/prettier": ["warn", {}, { usePrettierrc: true }], + "vue/attribute-hyphenation": [ + "warn", + "always", + { ignore: ["selectedFilters"] }, + ], + "vue/no-v-html": ["off"], + "vue/no-v-text-v-html-on-component": ["off"], + "vuejs-accessibility/anchor-has-content": [ + "error", + { + accessibleDirectives: ["tooltip"], + }, + ], + "vuejs-accessibility/label-has-for": [ + "error", + { + controlComponents: ["AppInput"], + required: { + some: ["nesting", "id"], + }, + allowChildren: true, + }, + ], + "vuejs-accessibility/mouse-events-have-key-events": ["off"], + }, + }, +); diff --git a/frontend/fixtures/index.ts b/frontend/fixtures/index.ts index 674e40af0..5a28dd18f 100644 --- a/frontend/fixtures/index.ts +++ b/frontend/fixtures/index.ts @@ -14,7 +14,7 @@ import phenotypeExplorerCompare from "./phenotype-explorer-compare.json"; import phenotypeExplorerMulticompare from "./phenotype-explorer-multi-compare.json"; import phenotypeExplorerSearch from "./phenotype-explorer-search.json"; import search from "./search.json"; -import textAnnotator from "./text-annotator.json"; +import textAnnotator from "./text-annotator.json" with { type: "json" }; import uptime from "./uptime.json"; /** api calls to be mocked with fixture data */ diff --git a/frontend/package.json b/frontend/package.json index 229af414b..36e4d4065 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,75 +1,76 @@ { "name": "monarch-app", + "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "eslint . --ext .vue,.ts,.js,.cjs,.mjs --fix && prettier --write \"**/*.{vue,ts,js,css,scss,json,yaml,svg,md}\"", + "lint": "eslint . --fix", "test": "bun run test:types && bun run test:lint && bun run test:unit && bun run test:e2e", "test:types": "vue-tsc --noEmit -p tsconfig.json --composite false", - "test:lint": "eslint . --ext .vue,.ts,.js,.cjs,.mjs && prettier --check \"**/*.{vue,ts,js,css,scss,json,yaml,svg,md}\"", + "test:lint": "eslint .", "test:unit": "vitest run", "test:e2e": "playwright test" }, "dependencies": { - "@duckdb/duckdb-wasm": "^1.28.0", - "@floating-ui/dom": "^1.6.3", - "@fortawesome/fontawesome-svg-core": "^6.5.1", - "@fortawesome/free-brands-svg-icons": "^6.5.1", - "@fortawesome/free-regular-svg-icons": "^6.5.1", - "@fortawesome/free-solid-svg-icons": "^6.5.1", - "@fortawesome/vue-fontawesome": "^3.0.6", - "@sentry/browser": "^7.105.0", - "@sentry/vue": "^7.105.0", - "@vueuse/core": "^10.9.0", - "apexcharts": "^3.46.0", + "@duckdb/duckdb-wasm": "^1.32.0", + "@floating-ui/dom": "^1.7.4", + "@fortawesome/fontawesome-svg-core": "^7.1.0", + "@fortawesome/free-brands-svg-icons": "^7.1.0", + "@fortawesome/free-regular-svg-icons": "^7.1.0", + "@fortawesome/free-solid-svg-icons": "^7.1.0", + "@fortawesome/vue-fontawesome": "^3.1.3", + "@sentry/browser": "^10.35.0", + "@sentry/vue": "^10.35.0", + "@vueuse/core": "^13.9.0", + "apexcharts": "^5.3.6", "body-scroll-lock": "4.0.0-beta.0", "echarts": "^6.0.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "lodash": "^4.17.21", - "micromark": "^4.0.0", + "micromark": "^4.0.2", "normalize.css": "^8.0.1", "tippy.js": "^6.3.7", - "ua-parser-js": "^1.0.37", - "vue": "^3.4.21", - "vue-gtag": "^2.0.1", + "ua-parser-js": "^2.0.8", + "vue": "^3.5.27", + "vue-gtag": "^3.6.3", "vue-hotjar": "^1.4.0", - "vue-router": "^4.3.0", - "vue-tippy": "^6.4.1", - "vue3-apexcharts": "~1.4.4" + "vue-router": "^4.6.4", + "vue-tippy": "^6.7.1", + "vue3-apexcharts": "^1.10.0" }, "devDependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.1.1", - "@playwright/test": "~1.39.0", - "@tsconfig/node20": "^20.1.2", + "@ianvs/prettier-plugin-sort-imports": "^4.7.0", + "@playwright/test": "~1.55.1", + "@tsconfig/node24": "^24.0.4", "@types/body-scroll-lock": "^3.1.2", "@types/dom-to-image": "^2.6.7", "@types/js-yaml": "^4.0.9", - "@types/jsdom": "^21.1.6", - "@types/lodash": "^4.14.202", + "@types/jsdom": "^21.1.7", + "@types/lodash": "^4.17.23", "@types/node": "20.11.24", "@types/ua-parser-js": "^0.7.39", - "@vitejs/plugin-vue": "^4.6.2", - "@vue/eslint-config-prettier": "^9.0.0", - "@vue/eslint-config-typescript": "^12.0.0", - "@vue/test-utils": "2.4.4", - "@vue/tsconfig": "^0.5.1", - "axe-playwright": "^2.0.1", - "eslint": "^8.57.0", - "eslint-plugin-vue": "^9.22.0", - "eslint-plugin-vuejs-accessibility": "^2.2.1", - "jsdom": "^24.0.0", - "msw": "^2.2.2", - "postcss": "^8.4.35", - "prettier": "^3.2.5", - "prettier-plugin-css-order": "^2.0.1", - "prettier-plugin-jsdoc": "^1.3.0", - "sass": "^1.71.1", - "typescript": "^5.3.3", - "vite": "^5.1.4", + "@vitejs/plugin-vue": "^6.0.3", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/test-utils": "2.4.6", + "@vue/tsconfig": "^0.8.1", + "axe-playwright": "^2.2.2", + "eslint": "^9.39.2", + "eslint-plugin-vue": "^10.7.0", + "eslint-plugin-vuejs-accessibility": "^2.4.1", + "jsdom": "^26.1.0", + "msw": "^2.12.7", + "postcss": "^8.5.6", + "prettier": "^3.8.0", + "prettier-plugin-css-order": "^2.2.0", + "prettier-plugin-jsdoc": "^1.8.0", + "sass": "^1.97.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", "vite-svg-loader": "^5.1.0", - "vitest": "^1.3.1", - "vue-tsc": "^2.0.4" + "vitest": "^3.2.4", + "vue-tsc": "^3.2.2" }, "msw": { "workerDirectory": "public" diff --git a/frontend/public/mockServiceWorker.js b/frontend/public/mockServiceWorker.js index 2d139dac5..461e2600e 100644 --- a/frontend/public/mockServiceWorker.js +++ b/frontend/public/mockServiceWorker.js @@ -5,24 +5,23 @@ * Mock Service Worker. * @see https://github.com/mswjs/msw * - Please do NOT modify this file. - * - Please do NOT serve this file on production. */ -const PACKAGE_VERSION = '2.2.13' -const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423' +const PACKAGE_VERSION = '2.12.7' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') const activeClientIds = new Set() -self.addEventListener('install', function () { +addEventListener('install', function () { self.skipWaiting() }) -self.addEventListener('activate', function (event) { +addEventListener('activate', function (event) { event.waitUntil(self.clients.claim()) }) -self.addEventListener('message', async function (event) { - const clientId = event.source.id +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') if (!clientId || !self.clients) { return @@ -62,16 +61,16 @@ self.addEventListener('message', async function (event) { sendToClient(client, { type: 'MOCKING_ENABLED', - payload: true, + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, }) break } - case 'MOCK_DEACTIVATE': { - activeClientIds.delete(clientId) - break - } - case 'CLIENT_CLOSED': { activeClientIds.delete(clientId) @@ -89,72 +88,99 @@ self.addEventListener('message', async function (event) { } }) -self.addEventListener('fetch', function (event) { - const { request } = event +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() // Bypass navigation requests. - if (request.mode === 'navigate') { + if (event.request.mode === 'navigate') { return } // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { return } // Bypass all requests when there are no active clients. // Prevents the self-unregistered worked from handling requests - // after it's been deleted (still remains active until the next reload). + // after it's been terminated (still remains active until the next reload). if (activeClientIds.size === 0) { return } - // Generate unique request ID. const requestId = crypto.randomUUID() - event.respondWith(handleRequest(event, requestId)) + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) }) -async function handleRequest(event, requestId) { +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) // Send back the response clone for the "response:*" life-cycle events. // Ensure MSW is active and ready to handle the message, otherwise // this message will pend indefinitely. if (client && activeClientIds.has(client.id)) { - ;(async function () { - const responseClone = response.clone() - - sendToClient( - client, - { - type: 'RESPONSE', - payload: { - requestId, - isMockedResponse: IS_MOCKED_RESPONSE in response, + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { type: responseClone.type, status: responseClone.status, statusText: responseClone.statusText, - body: responseClone.body, headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, }, }, - [responseClone.body], - ) - })() + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) } return response } -// Resolve the main client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ async function resolveMainClient(event) { const client = await self.clients.get(event.clientId) + if (activeClientIds.has(event.clientId)) { + return client + } + if (client?.frameType === 'top-level') { return client } @@ -175,20 +201,39 @@ async function resolveMainClient(event) { }) } -async function getResponse(event, client, requestId) { - const { request } = event - +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { // Clone the request because it might've been already used // (i.e. its body has been read and sent to the client). - const requestClone = request.clone() + const requestClone = event.request.clone() function passthrough() { - const headers = Object.fromEntries(requestClone.headers.entries()) + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) - // Remove internal MSW request header so the passthrough request - // complies with any potential CORS preflight checks on the server. - // Some servers forbid unknown request headers. - delete headers['x-msw-intention'] + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } return fetch(requestClone, { headers }) } @@ -207,29 +252,18 @@ async function getResponse(event, client, requestId) { } // Notify the client that a request has been intercepted. - const requestBuffer = await request.arrayBuffer() + const serializedRequest = await serializeRequest(event.request) const clientMessage = await sendToClient( client, { type: 'REQUEST', payload: { id: requestId, - url: request.url, - mode: request.mode, - method: request.method, - headers: Object.fromEntries(request.headers.entries()), - cache: request.cache, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body: requestBuffer, - keepalive: request.keepalive, + interceptedAt: requestInterceptedAt, + ...serializedRequest, }, }, - [requestBuffer], + [serializedRequest.body], ) switch (clientMessage.type) { @@ -245,6 +279,12 @@ async function getResponse(event, client, requestId) { return passthrough() } +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel() @@ -257,14 +297,18 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data) } - client.postMessage( - message, - [channel.port2].concat(transferrables.filter(Boolean)), - ) + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) }) } -async function respondWithMock(response) { +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { // Setting response status code to 0 is a no-op. // However, when responding with a "Response.error()", the produced Response // instance will have status code set to 0. Since it's not possible to create @@ -282,3 +326,24 @@ async function respondWithMock(response) { return mockedResponse } + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/frontend/src/api/phenotype-explorer.ts b/frontend/src/api/phenotype-explorer.ts index 139a8a666..2a2a0104a 100644 --- a/frontend/src/api/phenotype-explorer.ts +++ b/frontend/src/api/phenotype-explorer.ts @@ -159,7 +159,7 @@ export function processMulticompareResponse( total: 0, })); let rows: Phenogrid["cols"] = Object.values( - response[0].similarity?.subject_termset || [], + response[0]?.similarity?.subject_termset || [], ).map((entry) => ({ id: entry.id, label: entry.label, diff --git a/frontend/src/api/publications.ts b/frontend/src/api/publications.ts index e258dcc38..250d629d1 100644 --- a/frontend/src/api/publications.ts +++ b/frontend/src/api/publications.ts @@ -11,6 +11,19 @@ export const getPublication = async (id = ""): Promise => { const summary = (await getSummaries([id]))[id]; const abstract = await getAbstract(id); + if (!summary) { + console.warn(`Publication not found: ${id}`); + return { + id, + title: "", + authors: [], + date: new Date(0), + doi: "", + journal: "", + abstract: "", + }; + } + return { ...summary, abstract }; }; diff --git a/frontend/src/components/AppGallery.vue b/frontend/src/components/AppGallery.vue index 13c66a3ea..e86d5dc8e 100644 --- a/frontend/src/components/AppGallery.vue +++ b/frontend/src/components/AppGallery.vue @@ -53,7 +53,7 @@ $phone: 600px; grid-auto-rows: 1fr; place-content: center; // when content doesn't fill first row, limit width of gallery so that cell size is same as if first row was full - // e.g. on team page, so portraits in groups with only 1-2 members aren't bigger than portraits in groups with 3+ members + // e.g. team page, so portraits in groups with only 1-2 members aren't bigger than portraits in groups with 3+ members max-width: calc( (var(--cell)) * var(--content-cols) + (var(--content-cols) - 1) * var(--gap) ); diff --git a/frontend/src/components/AppHighlight.vue b/frontend/src/components/AppHighlight.vue index 502ccbda5..e74efdab3 100644 --- a/frontend/src/components/AppHighlight.vue +++ b/frontend/src/components/AppHighlight.vue @@ -12,9 +12,9 @@