diff --git a/app/client/lib/domUtils.ts b/app/client/lib/domUtils.ts index 2d46e324..fa0cf94e 100644 --- a/app/client/lib/domUtils.ts +++ b/app/client/lib/domUtils.ts @@ -64,3 +64,25 @@ export function stopEvent(ev: Event) { ev.preventDefault(); ev.stopImmediatePropagation(); } + +/** + * Adds a handler for a custom event triggered by `domDispatch` function below. + */ +export function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) { + return (el: Element) => { + dom.onElem(el, name, (ev, target) => { + const cv = ev as CustomEvent; + handler(cv.detail, ev, target); + }); + }; +} + +/** + * Triggers a custom event on an element. + */ +export function domDispatch(element: Element, name: string, args?: any) { + element.dispatchEvent(new CustomEvent(name, { + bubbles: true, + detail: args + })); +} diff --git a/app/client/lib/uploads.ts b/app/client/lib/uploads.ts index d9f8fdd4..d639adca 100644 --- a/app/client/lib/uploads.ts +++ b/app/client/lib/uploads.ts @@ -48,13 +48,18 @@ export async function selectFiles(options: SelectFileOptions, if (typeof electronSelectFiles === 'function') { result = await electronSelectFiles(getElectronOptions(options)); } else { - const files: File[] = await openFilePicker(getFileDialogOptions(options)); - result = await uploadFiles(files, options, onProgress); + result = await uploadFiles(await selectPicker(options), options, onProgress); } onProgress(100); return result; } +export async function selectPicker(options: SelectFileOptions) { + const files: File[] = await openFilePicker(getFileDialogOptions(options)); + return files; +} + + // Helper to convert SelectFileOptions to the browser's FileDialogOptions. function getFileDialogOptions(options: SelectFileOptions): FileDialogOptions { const resOptions: FileDialogOptions = {}; diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index 8449d1c9..c125185d 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -1,7 +1,7 @@ import {getWelcomeHomeUrl, urlState} from 'app/client/models/gristUrlState'; import {getTheme} from 'app/client/ui/CustomThemes'; import {cssLeftPane} from 'app/client/ui/PagePanels'; -import {colors, testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {colors, theme, vars} from 'app/client/ui2018/cssVars'; import * as version from 'app/common/version'; import {menu, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {commonUrls} from 'app/common/gristUrls'; @@ -15,8 +15,11 @@ import {maybeAddSiteSwitcherSection} from 'app/client/ui/SiteSwitcher'; import {Computed, Disposable, dom, DomContents, styled} from 'grainjs'; import {makeT} from 'app/client/lib/localization'; import {getGristConfig} from 'app/common/urlUtils'; +import {makeTestId} from 'app/client/lib/domUtils'; +import {createTeamImage, createUserImage, cssUserImage} from 'app/client/ui/UserImage'; const t = makeT('AppHeader'); +const testId = makeTestId('test-dm-'); // Maps a name of a Product (from app/gen-server/entity/Product.ts) to a tag (pill) to show next // to the org name. @@ -68,26 +71,63 @@ export class AppHeader extends Disposable { private _appLogoOrgLink = Computed.create(this, this._appLogoOrg, (_use, {link}) => link); - constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) { + constructor( + private _appModel: AppModel, + private _docPageModel?: DocPageModel|null) { super(); } public buildDom() { + // Check if we have a custom image. + const customImage = this._appModel.currentOrg?.orgPrefs?.customLogoUrl; + + const variant = () => [cssUserImage.cls('-border'), cssUserImage.cls('-square')]; + + // Personal avatar is shown only for logged in users. + const personalAvatar = () => !this._appModel.currentValidUser + ? cssAppLogo.cls('-grist-logo') + : createUserImage(this._appModel.currentValidUser, 'medium', variant()); + + // Team avatar is shown only for team sites (even for anonymous users). + const teamAvatar = () => !this._currentOrg + ? cssAppLogo.cls('-grist-logo') + : createTeamImage(this._currentOrg, 'medium', variant()); + + // Depending on site the avatar is either personal or team. + const avatar = () => this._appModel.isPersonal + ? personalAvatar() + : teamAvatar(); + + // Show the image if it's set, otherwise show the avatar. + const image = () => customImage + ? dom.style('background-image', customImage ? `url(${customImage})` : '') + : avatar(); + + + // Maybe we should show custom logo and make it wide (without site switcher). const productFlavor = getTheme(this._appModel.topAppModel.productFlavor); + const content = () => productFlavor.wideLogo + ? null + : image(); + + const title = `Version ${version.version}` + + ((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : ''); return cssAppHeader( cssAppHeader.cls('-widelogo', productFlavor.wideLogo || false), - dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo( - // Show version when hovering over the application icon. - // Include gitcommit when known. Cast version.gitcommit since, depending - // on how Grist is compiled, tsc may believe it to be a constant and - // believe that testing it is unnecessary. - {title: `Version ${version.version}` + - ((version.gitcommit as string) !== 'unknown' ? ` (${version.gitcommit})` : '')}, - this._setHomePageUrl(orgLink), - testId('dm-logo') - )), - this._buildOrgLinkOrMenu(), + cssAppHeaderBox( + dom.domComputed(this._appLogoOrgLink, orgLink => cssAppLogo( + // Show version when hovering over the application icon. + // Include gitcommit when known. Cast version.gitcommit since, depending + // on how Grist is compiled, tsc may believe it to be a constant and + // believe that testing it is unnecessary. + {title}, + this._setHomePageUrl(orgLink), + content(), + testId('logo'), + )), + this._buildOrgLinkOrMenu(), + ), ); } @@ -97,15 +137,18 @@ export class AppHeader extends Disposable { if (deploymentType === 'saas' && !currentValidUser && isTemplatesSite) { // When signed out and on the templates site (in SaaS Grist), link to the templates page. return cssOrgLink( - cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')), + cssOrgName(dom.text(this._appLogoOrgName), testId('orgname')), {href: commonUrls.templates}, - testId('dm-org'), + testId('org'), ); } else { return cssOrg( - cssOrgName(dom.text(this._appLogoOrgName), testId('dm-orgname')), + cssOrgName(dom.text(this._appLogoOrgName), testId('orgname')), productPill(this._currentOrg), - dom.maybe(this._appLogoOrgName, () => cssDropdownIcon('Dropdown')), + dom.maybe(this._appLogoOrgName, () => [ + cssSpacer(), + cssDropdownIcon('Dropdown'), + ]), menu(() => [ menuSubHeader( this._appModel.isPersonal @@ -128,7 +171,7 @@ export class AppHeader extends Disposable { maybeAddSiteSwitcherSection(this._appModel), ], { placement: 'bottom-start' }), - testId('dm-org'), + testId('org'), ); } } @@ -232,16 +275,22 @@ export function productPill(org: Organization|null, options: {large?: boolean} = return cssProductPill(cssProductPill.cls('-' + pillTag), options.large ? cssProductPill.cls('-large') : null, pillTag, - testId('appheader-product-pill')); + testId('product-pill')); } -const cssAppHeader = styled('div', ` - display: flex; +const cssAppHeader = styled('div._cssAppHeader', ` width: 100%; height: 100%; - align-items: center; background-color: ${theme.leftPanelBg}; + padding: 0px; + padding: 8px; + .${cssLeftPane.className}-open & { + padding: 8px 16px; + } + &-widelogo { + padding: 0px !important; + } &, &:hover, &:focus { text-decoration: none; outline: none; @@ -249,23 +298,54 @@ const cssAppHeader = styled('div', ` } `); -const cssAppLogo = styled('a', ` +const cssAppHeaderBox = styled('div._cssAppHeaderBox', ` + display: flex; + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; + background-color: ${theme.appHeaderBg}; + border: 1px solid ${theme.appHeaderBorder}; + border-radius: 4px; + &:hover { + background-color: ${theme.appHeaderHoverBg}; + } + .${cssAppHeader.className}-widelogo & { + border: none !important; + overflow: visible; + } +`); + +const cssAppLogo = styled('a._cssAppLogo', ` flex: none; - height: 48px; - width: 48px; - background-image: var(--icon-GristLogo); - background-size: ${vars.logoSize}; + height: 100%; + aspect-ratio: 1 / 1; + text-decoration: none; background-repeat: no-repeat; background-position: center; - background-color: ${vars.logoBg}; + background-color: inherit; + background-size: cover; + + &-grist-logo { + background-image: var(--icon-GristLogo); + background-color: ${vars.logoBg}; + background-size: ${vars.logoSize}; + } + .${cssAppHeader.className}-widelogo & { width: 100%; background-size: contain; background-origin: content-box; padding: 8px; + border-right: none !important; + background-size: contain; } .${cssLeftPane.className}-open .${cssAppHeader.className}-widelogo & { background-image: var(--icon-GristWideLogo, var(--icon-GristLogo)); + background-size: contain; + } + &:hover { + text-decoration: none; } `); @@ -275,25 +355,28 @@ const cssDropdownIcon = styled(icon, ` margin-right: 8px; `); -const cssOrg = styled('div', ` +const cssSpacer = styled('div', ` + display: none; + flex: 1; + display: block; +`); + +const cssOrg = styled('div._cssOrg', ` display: none; flex-grow: 1; + flex-basis: 0px; + overflow: hidden; align-items: center; - max-width: calc(100% - 48px); cursor: pointer; height: 100%; font-weight: 500; - - &:hover { - background-color: ${theme.hover}; - } - .${cssLeftPane.className}-open & { display: flex; + border-left: 1px solid ${theme.appHeaderBorder}; } `); -const cssOrgLink = styled('a', ` +const cssOrgLink = styled('a.cssOrgLink', ` display: none; flex-grow: 1; align-items: center; @@ -308,6 +391,10 @@ const cssOrgLink = styled('a', ` text-decoration: none; } + .${cssLeftPane.className}-open & { + border-left: 1px solid ${theme.appHeaderBorder}; + } + &:hover { color: ${theme.text}; background-color: ${theme.hover}; diff --git a/app/client/ui/CustomThemes.ts b/app/client/ui/CustomThemes.ts index 11dfd685..1c2efe2e 100644 --- a/app/client/ui/CustomThemes.ts +++ b/app/client/ui/CustomThemes.ts @@ -1,6 +1,18 @@ -import {GristLoadConfig} from 'app/common/gristUrls'; +// TODO: document this all, no tests are exercising this code. + +import {getGristConfig} from 'app/common/urlUtils'; import {styled} from 'grainjs'; +/** + * Is this grist installation or someone's modified installation. We allow modifying logo + * at the right corner, and making it wider (removing site switcher in the process). + * + * If fieldLink, shows wide logo and hides the switcher, otherwise shows the regular logo. + * + * We can convert any org name to a ProductFlavor and any ProductFlavor to a CustomTheme. + * + * TODO: explain what is fieldlink, I think this is an user of custom Grist build. + */ export type ProductFlavor = 'grist' | 'fieldlink'; export interface CustomTheme { @@ -14,13 +26,15 @@ export function getFlavor(org?: string): ProductFlavor { const themeOrg = new URLSearchParams(window.location.search).get('__themeOrg'); if (themeOrg) { org = themeOrg; } - if (!org) { - const gristConfig: GristLoadConfig = (window as any).gristConfig; - org = gristConfig && gristConfig.org; - } + // If still not set, use the org from the config. + org ||= getGristConfig()?.org; + + // If the org is 'fieldlink', use the fieldlink flavor. if (org === 'fieldlink') { return 'fieldlink'; } + + // For any other situation, use the grist flavor. return 'grist'; } diff --git a/app/client/ui/UserImage.ts b/app/client/ui/UserImage.ts index 167de8c6..0bf61f54 100644 --- a/app/client/ui/UserImage.ts +++ b/app/client/ui/UserImage.ts @@ -5,35 +5,60 @@ import {icon} from 'app/client/ui2018/icons'; export type Size = 'small' | 'medium' | 'large'; +interface OrgProperties { + name: string; + domain: string|null; +} + +/** Helper wrapper around OrgProfile that converts it to UserProfile */ +function OrgUser(org: OrgProperties): UserProfile { + return {name: org.name, email: org.domain || ''}; +} + /** * Returns a DOM element showing a circular icon with a user's picture, or the user's initials if * picture is missing. Also varies the color of the circle when using initials. */ export function createUserImage( - user: UserProfile|'exampleUser'|null, size: Size, ...args: DomElementArg[] + user: Partial|'exampleUser'|null, size: Size, ...args: DomElementArg[] ): HTMLElement { - let initials: string; return cssUserImage( cssUserImage.cls('-' + size), - (user === 'exampleUser') ? [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')] : - (!user || user.anonymous) ? cssUserImage.cls('-anon') : - [ - (user.picture ? cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))) : null), - dom.style('background-color', pickColor(user)), - (initials = getInitials(user)).length > 1 ? cssUserImage.cls('-reduced') : null, - initials!, - ], + ...(function*() { + if (user === 'exampleUser') { + yield [cssUserImage.cls('-example'), cssExampleUserIcon('EyeShow')]; + } else if (!user || user.anonymous) { + yield cssUserImage.cls('-anon'); + } else { + if (user.picture) { + yield cssUserPicture({src: user.picture}, dom.on('error', (ev, el) => dom.hideElem(el, true))); + } + yield dom.style('background-color', pickColor(user)); + const initials = getInitials(user); + if (initials.length > 1) { + yield cssUserImage.cls('-reduced'); + } + yield initials; + } + })(), ...args, ); } +/** + * Returns a DOM element showing team's initials as a circular icon. + */ +export function createTeamImage(org: OrgProperties, size: Size, ...args: DomElementArg[]): HTMLElement { + return createUserImage(OrgUser(org), size, ...args); +} + /** * Extracts initials from a user, e.g. a FullUser. E.g. "Foo Bar" is turned into "FB", and * "foo@example.com" into just "f". * * Exported for testing. */ -export function getInitials(user: {name?: string, email?: string}) { +export function getInitials(user: Partial) { const source = (user.name && user.name.trim()) || (user.email && user.email.trim()) || ''; return source.split(/\s+/, 2).map(p => p.slice(0, 1)).join(''); } @@ -41,7 +66,7 @@ export function getInitials(user: {name?: string, email?: string}) { /** * Hashes the username to return a color. */ -function pickColor(user: UserProfile): string { +function pickColor(user: Partial): string { let c = hashCode(user.name + ':' + user.email) % someColors.length; if (c < 0) { c += someColors.length; } return someColors[c]; @@ -87,22 +112,26 @@ export const cssUserImage = styled('div', ` display: flex; align-items: center; justify-content: center; + --border-size: 0px; + width: calc(var(--icon-size, 24px) - var(--border-size)); + height: calc(var(--icon-size, 24px) - var(--border-size)); + line-height: 1em; &-small { - width: 24px; - height: 24px; + --icon-size: 24px; font-size: 13.5px; --reduced-font-size: 12px; } &-medium { - width: 32px; - height: 32px; + --icon-size: 32px; font-size: 18px; --reduced-font-size: 16px; } + &-border { + --border-size: 2px; + } &-large { - width: 40px; - height: 40px; + --icon-size: 40px; font-size: 22.5px; --reduced-font-size: 20px; } @@ -117,7 +146,9 @@ export const cssUserImage = styled('div', ` &-reduced { font-size: var(--reduced-font-size); } - + &-square { + border-radius: 0px; + } &-example { background-color: ${colors.slate}; border: 1px solid ${colors.slate}; @@ -130,7 +161,7 @@ const cssUserPicture = styled('img', ` height: 100%; object-fit: cover; background-color: ${theme.menuBg}; - border-radius: 100px; + border-radius: inherit; box-sizing: content-box; /* keep the border outside of the size of the image */ `); diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index 45573e15..4cbe8573 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -903,6 +903,11 @@ export const theme = { colors.mediumGreyOpaque), markdownCellMediumBorder: new CustomProp('theme-markdown-cell-medium-border', undefined, colors.darkGrey), + + /* App Header */ + appHeaderBg: new CustomProp('theme-app-header-bg', undefined, colors.light), + appHeaderBorder: new CustomProp('theme-app-header-border', undefined, colors.mediumGreyOpaque), + appHeaderHoverBg: new CustomProp('theme-app-header-hover-bg', undefined, colors.hover), }; const cssColors = values(colors).map(v => v.decl()).join('\n'); diff --git a/app/client/widgets/DiscussionEditor.ts b/app/client/widgets/DiscussionEditor.ts index 1a88e23f..3e638fb4 100644 --- a/app/client/widgets/DiscussionEditor.ts +++ b/app/client/widgets/DiscussionEditor.ts @@ -1,11 +1,14 @@ +import {createPopper, Options as PopperOptions} from '@popperjs/core'; import {GristDoc} from 'app/client/components/GristDoc'; -import {makeT} from 'app/client/lib/localization'; +import {autoFocus, domDispatch, domOnCustom} from 'app/client/lib/domUtils'; import {FocusLayer} from 'app/client/lib/FocusLayer'; import {createObsArray} from 'app/client/lib/koArrayWrap'; +import {makeT} from 'app/client/lib/localization'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {CellRec, ColumnRec, ViewSectionRec} from 'app/client/models/DocModel'; import {reportError} from 'app/client/models/errors'; import {RowSource, RowWatcher} from 'app/client/models/rowset'; +import {autoGrow} from 'app/client/ui/forms'; import {createUserImage} from 'app/client/ui/UserImage'; import {basicButton, primaryButton, textButton} from 'app/client/ui2018/buttons'; import {labeledSquareCheckbox} from 'app/client/ui2018/checkbox'; @@ -29,13 +32,10 @@ import { Observable, styled } from 'grainjs'; -import {createPopper, Options as PopperOptions} from '@popperjs/core'; import * as ko from 'knockout'; import moment from 'moment'; import maxSize from 'popper-max-size-modifier'; import flatMap = require('lodash/flatMap'); -import {autoGrow} from 'app/client/ui/forms'; -import {autoFocus} from 'app/client/lib/domUtils'; const testId = makeTestId('test-discussion-'); const t = makeT('DiscussionEditor'); @@ -416,7 +416,7 @@ class CommentView extends Disposable { this.props.isReply ? testId('reply') : testId('comment'), dom.on('click', () => { if (this.props.isReply) { return; } - trigger(this._bodyDom, CommentView.SELECT, comment); + domDispatch(this._bodyDom, CommentView.SELECT, comment); if (!this._resolved.get()) { return; } this._expanded.set(!this._expanded.get()); }), @@ -474,11 +474,11 @@ class CommentView extends Disposable { const value = text.get(); text.set(""); await topic.update(comment, value); - trigger(this._bodyDom, CommentView.CANCEL, this); + domDispatch(this._bodyDom, CommentView.CANCEL, this); this.isEditing.set(false); }, onCancel: () => { - trigger(this._bodyDom, CommentView.CANCEL, this); + domDispatch(this._bodyDom, CommentView.CANCEL, this); this.isEditing.set(false); }, mode: 'start', @@ -583,7 +583,7 @@ class CommentView extends Disposable { } private _edit() { - trigger(this._bodyDom, CommentView.EDIT, this); + domDispatch(this._bodyDom, CommentView.EDIT, this); this.isEditing.set(true); } } @@ -1334,21 +1334,7 @@ const cssHoverButton = styled(cssCloseButton, ` // transform: rotate(180deg); // `); -function domOnCustom(name: string, handler: (args: any, event: Event, element: Element) => void) { - return (el: Element) => { - dom.onElem(el, name, (ev, target) => { - const cv = ev as CustomEvent; - handler(cv.detail.args ?? {}, ev, target); - }); - }; -} -function trigger(element: Element, name: string, args?: any) { - element.dispatchEvent(new CustomEvent(name, { - bubbles: true, - detail: {args} - })); -} const cssResolvedBlock = styled('div', ` margin-top: 5px; diff --git a/app/common/BillingAPI.ts b/app/common/BillingAPI.ts index 76df8750..659f796f 100644 --- a/app/common/BillingAPI.ts +++ b/app/common/BillingAPI.ts @@ -149,7 +149,8 @@ export interface ILimit { export interface IBillingOrgSettings { name: string; - domain: string; + domain: string|null; + customLogoUrl?: string|null; } // Full description of billing account, including nested list of orgs and managers. @@ -204,7 +205,7 @@ export interface BillingAPI { getSubscription(): Promise; getBillingAccount(): Promise; updateBillingManagers(delta: ManagerDelta): Promise; - updateSettings(settings: IBillingOrgSettings): Promise; + updateSettings(settings: Partial): Promise; subscriptionStatus(planId: string): Promise; createFreeTeam(name: string, domain: string): Promise; createTeam(name: string, domain: string, plan: PlanSelection, next?: string): Promise<{ @@ -261,7 +262,7 @@ export class BillingAPIImpl extends BaseAPI implements BillingAPI { }); } - public async updateSettings(settings?: IBillingOrgSettings): Promise { + public async updateSettings(settings?: Partial): Promise { await this.request(`${this._url}/api/billing/settings`, { method: 'POST', body: JSON.stringify({ settings }) diff --git a/app/common/LoginSessionAPI.ts b/app/common/LoginSessionAPI.ts index 7af009ce..e85b6c4f 100644 --- a/app/common/LoginSessionAPI.ts +++ b/app/common/LoginSessionAPI.ts @@ -4,8 +4,8 @@ import {UserPrefs} from 'app/common/Prefs'; // User profile info for the user. When using Cognito, it is fetched during login. export interface UserProfile { email: string; // TODO: Used inconsistently: as lowercase login email or display email. - loginEmail?: string; // When set, this is consistently normalized (lowercase) login email. name: string; + loginEmail?: string; // When set, this is consistently normalized (lowercase) login email. picture?: string|null; // when present, a url to a public image of unspecified dimensions. anonymous?: boolean; // when present, asserts whether user is anonymous (not authorized). connectId?: string|null, // used by GristConnect to identify user in external provider. diff --git a/app/common/Prefs.ts b/app/common/Prefs.ts index a8bd6061..2b66cadf 100644 --- a/app/common/Prefs.ts +++ b/app/common/Prefs.ts @@ -56,7 +56,10 @@ export interface UserOrgPrefs extends Prefs { seenDocTours?: string[]; } -export type OrgPrefs = Prefs; +export interface OrgPrefs extends Prefs { + /* The URL (might be data url) of the custom logo to use for the org. */ + customLogoUrl?: string|null; +} /** * List of all deprecated warnings that user can see and dismiss. diff --git a/app/common/ThemePrefs-ti.ts b/app/common/ThemePrefs-ti.ts index e8dbcbd5..87a080ee 100644 --- a/app/common/ThemePrefs-ti.ts +++ b/app/common/ThemePrefs-ti.ts @@ -451,6 +451,9 @@ export const ThemeColors = t.iface([], { "markdown-cell-light-bg": "string", "markdown-cell-light-border": "string", "markdown-cell-medium-border": "string", + "app-header-bg": "string", + "app-header-hover-bg": "string", + "app-header-border": "string", }); const exportedTypeSuite: t.ITypeSuite = { diff --git a/app/common/ThemePrefs.ts b/app/common/ThemePrefs.ts index 671fba0c..c64642d9 100644 --- a/app/common/ThemePrefs.ts +++ b/app/common/ThemePrefs.ts @@ -589,6 +589,11 @@ export interface ThemeColors { 'markdown-cell-light-bg': string; 'markdown-cell-light-border': string; 'markdown-cell-medium-border': string; + + /* App Header */ + 'app-header-bg': string; + 'app-header-hover-bg': string; + 'app-header-border': string; } export const ThemePrefsChecker = createCheckers(ThemePrefsTI).ThemePrefs as CheckerT; diff --git a/app/common/themes/GristDark.ts b/app/common/themes/GristDark.ts index 3892c876..22159f46 100644 --- a/app/common/themes/GristDark.ts +++ b/app/common/themes/GristDark.ts @@ -568,4 +568,9 @@ export const GristDark: ThemeColors = { 'markdown-cell-light-bg': '#494958', 'markdown-cell-light-border': '#32323F', 'markdown-cell-medium-border': '#555563', + + /* App Header */ + 'app-header-bg': '#32323F', + 'app-header-border': '#FFFFFF00', + 'app-header-hover-bg': 'rgba(111,111,125,0.6)', }; diff --git a/app/common/themes/GristLight.ts b/app/common/themes/GristLight.ts index 125e8a93..e172b340 100644 --- a/app/common/themes/GristLight.ts +++ b/app/common/themes/GristLight.ts @@ -568,4 +568,9 @@ export const GristLight: ThemeColors = { 'markdown-cell-light-bg': '#F7F7F7', 'markdown-cell-light-border': '#E8E8E8', 'markdown-cell-medium-border': '#D9D9D9', + + /* App header */ + 'app-header-bg': 'var(--grist-theme-page-panels-main-panel-bg)', + 'app-header-border': 'var(--grist-theme-menu-border)', + 'app-header-hover-bg': 'var(--grist-theme-hover)', }; diff --git a/test/gen-server/lib/limits.ts b/test/gen-server/lib/limits.ts index 669915f1..4eb2d7c0 100644 --- a/test/gen-server/lib/limits.ts +++ b/test/gen-server/lib/limits.ts @@ -25,6 +25,8 @@ describe('limits', function() { testUtils.setTmpLogLevel('error'); + this.timeout('10s'); + before(async function() { home = new TestServer(this); await home.start(["home", "docs"]); @@ -222,7 +224,9 @@ describe('limits', function() { }); it('can enforce limits on number of doc shares', async function() { - this.timeout(4000); // This can exceed the default of 2s on Jenkins + // This can exceed the default of 2s on Jenkins + // - Changed from 4s to 8s on 2024-10-04 + this.timeout('8s'); await setFeatures({maxSharesPerDoc: 3, workspaces: true}); const wsId = await api.newWorkspace({name: 'shares'}, 'docs');