diff --git a/client/public/fonts/inconsolata-OFL.txt b/client/public/fonts/inconsolata-OFL.txt new file mode 100644 index 00000000000..79721add751 --- /dev/null +++ b/client/public/fonts/inconsolata-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2006 The Inconsolata Project Authors (https://github.com/cyrealtype/Inconsolata) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/public/fonts/inconsolata-latin-400-normal.woff2 b/client/public/fonts/inconsolata-latin-400-normal.woff2 new file mode 100644 index 00000000000..a5ef8233e02 Binary files /dev/null and b/client/public/fonts/inconsolata-latin-400-normal.woff2 differ diff --git a/client/public/fonts/inconsolata-latin-700-normal.woff2 b/client/public/fonts/inconsolata-latin-700-normal.woff2 new file mode 100644 index 00000000000..0f59953923d Binary files /dev/null and b/client/public/fonts/inconsolata-latin-700-normal.woff2 differ diff --git a/client/src/fonts.css b/client/src/fonts.css new file mode 100644 index 00000000000..ff4ab922a17 --- /dev/null +++ b/client/src/fonts.css @@ -0,0 +1,18 @@ +/* Faces a theme opts into rather than ones the default theme uses. Browsers fetch a declared face + * only once text renders in it, so these cost nothing until a theme names the family. + * Inconsolata is the ClickHouse theme's mono family (SIL OFL 1.1, see public/fonts/inconsolata-OFL.txt). */ +@font-face { + font-display: swap; + font-family: Inconsolata; + font-style: normal; + font-weight: 400; + src: url('$fonts/inconsolata-latin-400-normal.woff2') format('woff2'); +} + +@font-face { + font-display: swap; + font-family: Inconsolata; + font-style: normal; + font-weight: 700; + src: url('$fonts/inconsolata-latin-700-normal.woff2') format('woff2'); +} diff --git a/client/src/main.jsx b/client/src/main.jsx index f0f196e720a..703ba21314a 100644 --- a/client/src/main.jsx +++ b/client/src/main.jsx @@ -4,6 +4,7 @@ import { initializeI18n } from './locales/i18n'; import App from './App'; import '@librechat/client/style.css'; import './style.css'; +import './fonts.css'; import './mobile.css'; import { ApiErrorBoundaryProvider } from './hooks/ApiErrorBoundaryContext'; import 'katex/dist/katex.min.css'; diff --git a/client/vite.config.ts b/client/vite.config.ts index 5a279a92cac..fa1f0170820 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -462,8 +462,9 @@ export function sourcemapExclude(opts?: SourcemapExclude): Plugin { * Production builds set `publicDir: false`, so nothing under public/ reaches dist on its * own. This copies what the server actually has to serve: all of public/assets (the PWA * icons plus the endpoint, tool and language logos referenced at runtime) and robots.txt. - * public/fonts is deliberately left out, since fonts are emitted as bundle assets through - * the `$fonts` alias. + * The font files in public/fonts are emitted as bundle assets through the `$fonts` alias, + * so only their licence texts are copied, next to them in assets/fonts: the SIL OFL lets a + * font be redistributed only with its licence. * * The copy MUST happen inside the build. vite-plugin-pwa globs dist/ for * `workbox.globPatterns` from its `closeBundle` hook, which runs after every plugin's @@ -489,6 +490,18 @@ export function copyPublicAssets(): Plugin { path.join(publicDir, 'robots.txt'), path.join(outDir, 'robots.txt'), ); + const licences = (await fs.promises.readdir(path.join(publicDir, 'fonts'))).filter((name) => + name.endsWith('.txt'), + ); + await fs.promises.mkdir(path.join(outDir, 'assets', 'fonts'), { recursive: true }); + await Promise.all( + licences.map((name) => + fs.promises.copyFile( + path.join(publicDir, 'fonts', name), + path.join(outDir, 'assets', 'fonts', name), + ), + ), + ); }, }; } diff --git a/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts b/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts index bc58e56bf3c..df70b50bef8 100644 --- a/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts +++ b/e2e/specs/mock/scenarios/clickhouse-theme-shape.spec.ts @@ -54,6 +54,23 @@ async function openMentionMenu(page: Page): Promise { return menu; } +/** + * Paints code text, waits for the browser to settle its font loads, and reports the status of + * every declared Inconsolata face. A face is fetched only once text renders in it. + */ +async function inconsolataStatuses(page: Page): Promise { + return page.evaluate(async () => { + const probe = document.createElement('code'); + probe.textContent = 'const probe = 0;'; + document.body.append(probe); + await document.fonts.ready; + probe.remove(); + return [...document.fonts] + .filter((face) => face.family.replace(/"/g, '') === 'Inconsolata') + .map((face) => face.status); + }); +} + async function resolvedMode(page: Page): Promise { const dark = await page.evaluate(() => document.documentElement.classList.contains('dark')); return dark ? 'dark' : 'light'; @@ -104,4 +121,55 @@ test.describe('ClickHouse theme shape', () => { expect(family.replace(/"/g, '').startsWith('Inconsolata')).toBe(true); expect(family).toContain('monospace'); }); + + test('the ClickHouse theme renders code in the self-hosted Inconsolata face @scenario:clickhouse-mono-font-is-bundled', async ({ + page, + }) => { + const fetched: string[] = []; + page.on('requestfinished', (request) => { + if (/inconsolata-latin-\d+-normal/.test(request.url())) { + fetched.push(request.url()); + } + }); + await storeClickHouse(page); + + await openMentionMenu(page); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'clickhouse'); + + expect(await inconsolataStatuses(page)).toContain('loaded'); + expect(await page.evaluate(() => document.fonts.check('16px Inconsolata'))).toBe(true); + expect(fetched).not.toHaveLength(0); + }); + + test('the production build serves the Inconsolata licence beside the font @scenario:inconsolata-licence-ships-with-font', async ({ + page, + }) => { + const font = await page.request.get('/assets/fonts/inconsolata-latin-400-normal.woff2'); + expect(font.status()).toBe(200); + + const licence = await page.request.get('/assets/fonts/inconsolata-OFL.txt'); + expect(licence.status()).toBe(200); + const text = await licence.text(); + expect(text).toContain('Copyright 2006 The Inconsolata Project Authors'); + expect(text).toContain('SIL OPEN FONT LICENSE Version 1.1'); + }); + + test('the default theme never fetches Inconsolata @scenario:default-theme-skips-inconsolata', async ({ + page, + }) => { + const fetched: string[] = []; + page.on('request', (request) => { + if (/inconsolata/i.test(request.url())) { + fetched.push(request.url()); + } + }); + + await openMentionMenu(page); + await expect(page.locator('html')).not.toHaveAttribute('data-theme', 'clickhouse'); + + const statuses = await inconsolataStatuses(page); + expect(statuses).not.toHaveLength(0); + expect(statuses.every((status) => status === 'unloaded')).toBe(true); + expect(fetched).toEqual([]); + }); }); diff --git a/packages/client/src/theme/themes/clickhouse.ts b/packages/client/src/theme/themes/clickhouse.ts index 945e2cadfbb..9d7650a36c4 100644 --- a/packages/client/src/theme/themes/clickhouse.ts +++ b/packages/client/src/theme/themes/clickhouse.ts @@ -275,9 +275,9 @@ export const clickHouseDarkTheme: IThemeRGB = { * the dialog-sized steps onto `radii.2`, and the largest onto `radii.3`. `rounded-sm` takes * `radii.1`, which LibreChat's `sm` also renders at on a 16px root; every larger step tightens. * - * Click UI's mono family is Inconsolata. The app does not bundle it, so the tail is the same - * metric-matched stack the default theme uses (Click UI's own tail names `"SFMono Regular"`, - * which no platform installs). + * Click UI's mono family is Inconsolata, which the client self-hosts in `client/src/fonts.css` + * (latin 400 and 700). The tail is the same metric-matched stack the default theme uses (Click + * UI's own tail names `"SFMono Regular"`, which no platform installs). * * Click UI raises every elevated surface (card, dialog, menu, panel, popover, toast) with * `shadow.1`, and its only lighter step is the hairline `shadow.5`. Steps 2 to 4 are the flyout's