diff --git a/LICENSE b/LICENSE index 3e0b328e..29359f3e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 The Pybricks Authors +Copyright (c) 2020-2021 The Pybricks Authors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/craco.config.js b/craco.config.js index dd1d624a..4da4d1dc 100644 --- a/craco.config.js +++ b/craco.config.js @@ -1,3 +1,5 @@ +// Configuration file to override create-react-app. +// // https://github.com/gsoft-inc/craco/blob/master/packages/craco/README.md#configuration const { @@ -6,9 +8,234 @@ const { getLoaders, loaderByName, } = require('@craco/craco'); +const LicensePlugin = require('license-webpack-plugin').LicenseWebpackPlugin; +const satisfies = require('spdx-satisfies'); + +// Permissive licenses can be added here. We would like to avoid copyleft. +const approvedLicenses = ['0BSD', 'Apache-2.0', 'BSD-3-Clause', 'ISC', 'MIT']; + +/** Converts a package.json "person" to a string. + * + * Ref: https://docs.npmjs.com/cli/v6/configuring-npm/package-json#people-fields-author-contributors + * + * @param person a string or "person" object or undefined + * @returns A string or undefined. + */ +function personToString(person) { + if (typeof person === 'string' || typeof person === 'undefined') { + return person; + } + + let str = person.name; + if (person.email) { + str += ` <${person.email}>`; + } + if (person.url) { + str += ` (${person.url})`; + } + return str; +} + +// Some packages don't have a separate license file, so we have to copy the +// license here e.g. from the README. + +function fedorIndutnyLicense(year) { + return `This software is licensed under the MIT License. + +Copyright Fedor Indutny, ${year}. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE.`; +} + +const pybricksLicense = `MIT License + +Copyright (c) 2018-2021 The Pybricks Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +`; + +const shopifyLicense = `MIT License + +Copyright (c) 2021 Shopify + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`; + +const licenseTextOverrides = { + '@pybricks/firmware': pybricksLicense, + '@pybricks/mpy-cross-v5': pybricksLicense, + '@shopify/dates': shopifyLicense, + '@shopify/decorators': shopifyLicense, + '@shopify/function-enhancers': shopifyLicense, + '@shopify/i18n': shopifyLicense, + '@shopify/react': shopifyLicense, + '@shopify/react-hooks': shopifyLicense, + '@shopify/react-i18n': shopifyLicense, + 'bn.js': fedorIndutnyLicense(2015), + brorand: fedorIndutnyLicense(2014), + 'des.js': fedorIndutnyLicense(2015), + elliptic: fedorIndutnyLicense(2014), + 'hash.js': fedorIndutnyLicense(2014), + 'hmac-drbg': fedorIndutnyLicense(2017), + 'miller-rabin': fedorIndutnyLicense(2014), + 'minimalistic-crypto-utils': fedorIndutnyLicense(2017), + gud: `MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`, + isarray: `(MIT) + +Copyright (c) 2013 Julian Gruber + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`, + 'popper.js': `The MIT License (MIT) + +Copyright (c) 2019 Federico Zivolo + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, + 'zen-push': `Copyright (c) 2018 zenparsing (Kevin Smith) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, +}; module.exports = { webpack: { + plugins: [ + new LicensePlugin({ + outputFilename: 'static/oss-licenses.json', + perChunkOutput: false, + renderLicenses: (modules) => { + return JSON.stringify( + modules + .map((m) => ({ + name: m.packageJson.name, + version: m.packageJson.version, + author: personToString(m.packageJson.author), + license: m.licenseId, + licenseText: m.licenseText, + })) + .sort((a, b) => a.name.localeCompare(b.name, 'en')), + ); + }, + licenseTextOverrides, + additionalModules: [ + { name: '@pybricks/pybricks-code', directory: __dirname }, + ], + unacceptableLicenseTest: (licenseType) => + !satisfies(licenseType, `(${approvedLicenses.join(' OR ')})`), + }), + ], configure: { resolve: { // need 'esnext' first to avoid compile errors diff --git a/package.json b/package.json index cfdacca3..4223d04b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,12 @@ { - "name": "pybricks-code", + "name": "@pybricks/pybricks-code", "version": "0.1.0", - "private": true, + "license": "MIT", + "author": "The Pybricks Authors", + "repository": { + "type": "git", + "url": "https://github.com/pybricks/pybricks-code" + }, "dependencies": { "@blueprintjs/core": "^3.36.0", "@craco/craco": "^6.0.0", @@ -24,6 +29,7 @@ "@types/zen-push": "^0.1.1", "ace-builds": "^1.4.12", "file-saver": "^2.0.5", + "license-webpack-plugin": "^2.3.11", "node-sass": "^4.14.1", "prop-types": "^15.7.2", "react": "^16.13.1", @@ -36,6 +42,7 @@ "redux": "^4.0.5", "redux-logger": "^3.0.6", "redux-saga": "^1.1.3", + "spdx-satisfies": "^5.0.0", "typescript": "~4.1.3", "web-vitals": "^1.0.1", "xterm": "^4.9.0", diff --git a/src/actions/app.ts b/src/actions/app.ts index daaf204c..f6bda357 100644 --- a/src/actions/app.ts +++ b/src/actions/app.ts @@ -13,8 +13,14 @@ export enum AppActionType { OpenSettings = 'app.action.openSettings', /** Close settings dialog. */ CloseSettings = 'app.action.closeSettings', - /** Toggle documentation visibility. */ - ToggleDocs = 'app.action.toggleDocs', + /** Open about dialog. */ + OpenAboutDialog = 'app.action.openAboutDialog', + /** Close about dialog. */ + CloseAboutDialog = 'app.action.closeAboutDialog', + /** Open license dialog. */ + OpenLicenseDialog = 'app.action.openLicenseDialog', + /** Close license dialog. */ + CloseLicenseDialog = 'app.action.closeLicenseDialog', } /** Action that indicates the app has just started. */ @@ -41,12 +47,36 @@ export function closeSettings(): AppCloseSettingsAction { return { type: AppActionType.CloseSettings }; } -/** Action to toggle documentation visibility. */ -export type AppToggleDocsAction = Action; +/** Action to open the about dialog. */ +export type AppOpenAboutDialogAction = Action; -/** Creates an action to toggle documentation visibility. */ -export function toggleDocs(): AppToggleDocsAction { - return { type: AppActionType.ToggleDocs }; +/** Creates an action to open the about dialog. */ +export function openAboutDialog(): AppOpenAboutDialogAction { + return { type: AppActionType.OpenAboutDialog }; +} + +/** Action to close the about dialog. */ +export type AppCloseAboutDialogAction = Action; + +/** Creates an action to close the about dialog. */ +export function closeAboutDialog(): AppCloseAboutDialogAction { + return { type: AppActionType.CloseAboutDialog }; +} + +/** Action to open the license dialog. */ +export type AppOpenLicenseDialogAction = Action; + +/** Creates an action to open the license dialog. */ +export function openLicenseDialog(): AppOpenLicenseDialogAction { + return { type: AppActionType.OpenLicenseDialog }; +} + +/** Action to close the license dialog. */ +export type AppCloseLicenseDialogAction = Action; + +/** Creates an action to close the license dialog. */ +export function closeLicenseDialog(): AppCloseLicenseDialogAction { + return { type: AppActionType.CloseLicenseDialog }; } /** common type for all app actions. */ @@ -54,4 +84,7 @@ export type AppAction = | AppStartupAction | AppOpenSettingsAction | AppCloseSettingsAction - | AppToggleDocsAction; + | AppOpenAboutDialogAction + | AppCloseAboutDialogAction + | AppOpenLicenseDialogAction + | AppCloseLicenseDialogAction; diff --git a/src/actions/index.ts b/src/actions/index.ts index 11b549ac..e4ded3ad 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -8,6 +8,7 @@ import { BleUartAction } from './ble-uart'; import { EditorAction } from './editor'; import { FlashFirmwareAction } from './flash-firmware'; import { HubAction, HubMessageAction } from './hub'; +import { LicenseAction } from './license'; import { BootloaderConnectionAction, BootloaderDidRequestAction, @@ -36,6 +37,7 @@ export type Action = | FlashFirmwareAction | HubAction | HubMessageAction + | LicenseAction | MpyAction | NotificationAction | ServiceWorkerAction diff --git a/src/actions/license.ts b/src/actions/license.ts new file mode 100644 index 00000000..5a593a20 --- /dev/null +++ b/src/actions/license.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { Action } from 'redux'; +import { LicenseInfo, LicenseList } from '../reducers/license'; + +export enum LicenseActionType { + DidFetchList = 'license.action.didFetchList', + DidFailToFetchList = 'license.action.didFailToFetchList', + Select = 'license.action.select', +} + +export type LicenseDidFetchListAction = Action & { + list: LicenseList; +}; + +export function didFetchList(list: LicenseList): LicenseDidFetchListAction { + return { type: LicenseActionType.DidFetchList, list }; +} + +export type LicenseDidFailToFetchListAction = Action & { + reason: Response; +}; + +export function didFailToFetchList(reason: Response): LicenseDidFailToFetchListAction { + return { type: LicenseActionType.DidFailToFetchList, reason }; +} + +export type LicenseSelectAction = Action & { + info: LicenseInfo; +}; + +export function select(info: LicenseInfo): LicenseSelectAction { + return { type: LicenseActionType.Select, info }; +} + +export type LicenseAction = + | LicenseDidFetchListAction + | LicenseDidFailToFetchListAction + | LicenseSelectAction; diff --git a/src/actions/service-worker.ts b/src/actions/service-worker.ts index 6f97475d..25386eb5 100644 --- a/src/actions/service-worker.ts +++ b/src/actions/service-worker.ts @@ -4,8 +4,8 @@ import { Action } from 'redux'; export enum ServiceWorkerActionType { - Update = 'serviceWorker.update', - Success = 'serviceWorker.success', + DidUpdate = 'serviceWorker.didUpdate', + DidSucceed = 'serviceWorker.didSucceed', } export type ServiceWorkerAction< @@ -14,14 +14,14 @@ export type ServiceWorkerAction< registration: ServiceWorkerRegistration; }; -export function update( +export function didUpdate( registration: ServiceWorkerRegistration, -): ServiceWorkerAction { - return { type: ServiceWorkerActionType.Update, registration }; +): ServiceWorkerAction { + return { type: ServiceWorkerActionType.DidUpdate, registration }; } -export function success( +export function didSucceed( registration: ServiceWorkerRegistration, -): ServiceWorkerAction { - return { type: ServiceWorkerActionType.Success, registration }; +): ServiceWorkerAction { + return { type: ServiceWorkerActionType.DidSucceed, registration }; } diff --git a/src/actions/settings.ts b/src/actions/settings.ts index cfa4ba1a..e2f477d9 100644 --- a/src/actions/settings.ts +++ b/src/actions/settings.ts @@ -2,7 +2,7 @@ // Copyright (c) 2021 The Pybricks Authors import { Action } from 'redux'; -import { SettingId } from '../settings'; +import { SettingId } from '../settings/user'; /** Actions related to settings. */ export enum SettingsActionType { diff --git a/src/components/AboutDialog.tsx b/src/components/AboutDialog.tsx new file mode 100644 index 00000000..f7b506dc --- /dev/null +++ b/src/components/AboutDialog.tsx @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +// The about dialog + +import { AnchorButton, Button, Classes, Dialog } from '@blueprintjs/core'; +import { WithI18nProps, withI18n } from '@shopify/react-i18n'; +import React from 'react'; +import { connect } from 'react-redux'; +import { Action, Dispatch } from '../actions'; +import { closeAboutDialog, openLicenseDialog } from '../actions/app'; +import { RootState } from '../reducers'; +import { + appName, + legoDisclaimer, + pybricksCopyright, + pybricksWebsiteUrl, +} from '../settings/ui'; +import ExternalLinkIcon from './ExternalLinkIcon'; +import LicenseDialog from './LicenseDialog'; +import { AboutStringId } from './about-i18n'; +import en from './about-i18n.en.json'; + +import './about.scss'; + +type StateProps = { showAboutDialog: boolean }; + +type DispatchProps = { onClose: () => void; onLicenseButtonClick: () => void }; + +type AboutDialogProps = StateProps & DispatchProps & WithI18nProps; + +class AboutDialog extends React.Component { + render(): JSX.Element { + const { i18n, showAboutDialog, onClose, onLicenseButtonClick } = this.props; + return ( + onClose()}> +
+
+ +
+

+ {i18n.translate(AboutStringId.Description)} +

+

{pybricksCopyright}

+
+
+

+ {legoDisclaimer} +

+
+ + + {i18n.translate(AboutStringId.WebsiteButtonLabel)}  + + +
+
+ +
+ ); + } +} + +const mapStateToProps = (state: RootState): StateProps => ({ + showAboutDialog: state.app.showAboutDialog, +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onClose: (): Action => dispatch(closeAboutDialog()), + onLicenseButtonClick: (): Action => dispatch(openLicenseDialog()), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(withI18n({ id: 'about', fallback: en, translations: { en } })(AboutDialog)); diff --git a/src/components/ActionButton.tsx b/src/components/ActionButton.tsx index 23aad054..7377dcb6 100644 --- a/src/components/ActionButton.tsx +++ b/src/components/ActionButton.tsx @@ -12,6 +12,7 @@ import { } from '@blueprintjs/core'; import { WithI18nProps, withI18n } from '@shopify/react-i18n'; import React from 'react'; +import { tooltipDelay } from '../settings/ui'; import { TooltipId } from './button-i18n'; import en from './button-i18n.en.json'; @@ -42,7 +43,11 @@ class ActionButton extends React.Component { tooltipText += ` (${this.props.keyboardShortcut})`; } return ( - + + ))} + + )} + +
+ {licenseInfo == null ? ( + + {i18n.translate(LicenseStringId.SelectPackageHelp)} + + ) : ( +
+ +

+ + {i18n.translate( + LicenseStringId.PackageLabel, + )} + {' '} + {licenseInfo.name}{' '} + + v{licenseInfo.version} + +

+ {licenseInfo.author && ( +

+ + {' '} + {i18n.translate( + LicenseStringId.AuthorLabel, + )} + {' '} + {licenseInfo.author} +

+ )} +

+ + {' '} + {i18n.translate( + LicenseStringId.LicenseLabel, + )} + {' '} + {licenseInfo.license} +

+
+
+
{licenseInfo.licenseText}
+
+
+ )} +
+ + + + ); + } +} + +const mapStateToProps = (state: RootState): StateProps => ({ + showLicenseDialog: state.app.showLicenseDialog, + licenseList: state.license.list, + licenseInfo: state.license.selected, +}); + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + onClose: (): Action => dispatch(closeLicenseDialog()), + onSelectPackage: (info): Action => dispatch(select(info)), +}); + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(withI18n({ id: 'license', fallback: en, translations: { en } })(LicenseDialog)); diff --git a/src/components/LinkButton.tsx b/src/components/LinkButton.tsx index f795d9c1..4f8e8ac1 100644 --- a/src/components/LinkButton.tsx +++ b/src/components/LinkButton.tsx @@ -4,6 +4,7 @@ import { Button, Intent, Position, Tooltip } from '@blueprintjs/core'; import { WithI18nProps, withI18n } from '@shopify/react-i18n'; import React from 'react'; +import { tooltipDelay } from '../settings/ui'; import { TooltipId } from './button-i18n'; import en from './button-i18n.en.json'; @@ -28,6 +29,7 @@ class LinkButton extends React.Component { + + + + ); @@ -153,6 +218,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ dispatch(setBoolean(SettingId.DarkMode, checked)), onFlashCurrentProgramChanged: (checked): Action => dispatch(setBoolean(SettingId.FlashCurrentProgram, checked)), + onAbout: (): Action => dispatch(openAboutDialog()), }); export default connect( diff --git a/src/components/about-i18n.en.json b/src/components/about-i18n.en.json new file mode 100644 index 00000000..5c9a2b5f --- /dev/null +++ b/src/components/about-i18n.en.json @@ -0,0 +1,7 @@ +{ + "about": { + "description": "A simple web app for programming LEGO® Powered Up smart hubs using Pybricks MicroPython.", + "licenseButton": { "label": "Open Source Licenses" }, + "websiteButton": { "label": "Pybricks Website" } + } +} diff --git a/src/components/about-i18n.test.ts b/src/components/about-i18n.test.ts new file mode 100644 index 00000000..3c94a2b1 --- /dev/null +++ b/src/components/about-i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { lookup } from '../../test'; +import { AboutStringId } from './about-i18n'; +import en from './about-i18n.en.json'; + +describe('Ensure .json file has matches for AboutStringId', () => { + test.each(Object.values(AboutStringId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/components/about-i18n.ts b/src/components/about-i18n.ts new file mode 100644 index 00000000..04a9ba9d --- /dev/null +++ b/src/components/about-i18n.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +// About dialog translation keys. + +export enum AboutStringId { + Description = 'about.description', + LicenseButtonLabel = 'about.licenseButton.label', + WebsiteButtonLabel = 'about.websiteButton.label', +} diff --git a/src/components/about.scss b/src/components/about.scss new file mode 100644 index 00000000..d27e8f69 --- /dev/null +++ b/src/components/about.scss @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +.pb-about-icon { + float: left; + margin-right: 10px; +} diff --git a/src/components/app.scss b/src/components/app.scss index 626932eb..ecc1a7d8 100644 --- a/src/components/app.scss +++ b/src/components/app.scss @@ -5,7 +5,7 @@ @import '../variables.scss'; -.bp3-dark .splitter-layout > .layout-splitter { +.#{$ns}-dark .splitter-layout > .layout-splitter { // make layout splitter match app color scheme background-color: $pt-dark-app-background-color; } @@ -15,7 +15,7 @@ background-color: $pt-app-background-color; } -.bp3-dark .terminal-padding { +.#{$ns}-dark .terminal-padding { background-color: black; } diff --git a/src/components/editor.scss b/src/components/editor.scss index 71697224..37f6a23f 100644 --- a/src/components/editor.scss +++ b/src/components/editor.scss @@ -5,7 +5,7 @@ @import '../variables.scss'; -.bp3-dark .ace_gutter { +.#{$ns}-dark .ace_gutter { // make ace editor match app backgound color background-color: $pt-dark-app-background-color; } diff --git a/src/components/external-link.scss b/src/components/external-link.scss new file mode 100644 index 00000000..fb81f8d9 --- /dev/null +++ b/src/components/external-link.scss @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +@import '../variables.scss'; + +// override some button magic in case this is used in a button +.#{$ns}-button .pb-external-link .#{$ns}-icon:first-child:last-child { + margin: 0; +} diff --git a/src/components/license-i18n.en.json b/src/components/license-i18n.en.json new file mode 100644 index 00000000..e49f0af1 --- /dev/null +++ b/src/components/license-i18n.en.json @@ -0,0 +1,12 @@ +{ + "license": { + "title": "Open Source Licenses", + "description": "{name} is built on open source software. By using {name} you are agreeing to the terms and conditions of all of the included software licenses.", + "packageLabel": "Package:", + "authorLabel": "Author:", + "licenseLabel": "License:", + "help": { + "selectPackage": "Select a package to view the license." + } + } +} diff --git a/src/components/license-i18n.test.ts b/src/components/license-i18n.test.ts new file mode 100644 index 00000000..e4bc2625 --- /dev/null +++ b/src/components/license-i18n.test.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { lookup } from '../../test'; +import { LicenseStringId } from './license-i18n'; +import en from './license-i18n.en.json'; + +describe('Ensure .json file has matches for LicenseStringId', () => { + test.each(Object.values(LicenseStringId))('%s', (id) => { + expect(lookup(en, id)).toBeDefined(); + }); +}); diff --git a/src/components/license-i18n.ts b/src/components/license-i18n.ts new file mode 100644 index 00000000..da089f70 --- /dev/null +++ b/src/components/license-i18n.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +// License dialog translation keys. + +export enum LicenseStringId { + Title = 'license.title', + Description = 'license.description', + PackageLabel = 'license.packageLabel', + AuthorLabel = 'license.authorLabel', + LicenseLabel = 'license.licenseLabel', + SelectPackageHelp = 'license.help.selectPackage', +} diff --git a/src/components/license.scss b/src/components/license.scss new file mode 100644 index 00000000..0b997eaf --- /dev/null +++ b/src/components/license.scss @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020-2021 The Pybricks Authors + +// Custom styling for the LicenseDialog control. + +@import '../variables.scss'; + +.pb-license-dialog { + width: 1200px; +} + +.pb-license-browser { + margin-top: 16px; + display: flex; + height: 600px; +} + +.pb-license-list { + width: 25%; + flex-flow: column; + overflow: auto; +} + +.pb-license-info { + width: 75%; + flex-flow: column; + padding: 0px 16px; + overflow: auto; +} + +.pb-license-text { + padding: 0px 16px; +} diff --git a/src/components/settings-i18n.en.json b/src/components/settings-i18n.en.json index 72baa0e7..8e30f8bf 100644 --- a/src/components/settings-i18n.en.json +++ b/src/components/settings-i18n.en.json @@ -1,6 +1,6 @@ { "settings": { - "title": "Settings", + "title": "Settings & Help", "appearance": { "title": "Appearance", "documentation": { @@ -21,6 +21,21 @@ "label": "Include current program", "tooltip": "Select to include your program when installing the firmware." } + }, + "help": { + "title": "Help", + "support": { + "label": "Support" + }, + "chat": { + "label": "Chat" + }, + "bugs": { + "label": "Bug Reports" + }, + "about": { + "label": "About" + } } } } diff --git a/src/components/settings-i18n.ts b/src/components/settings-i18n.ts index 6dc5c3fd..13c68db1 100644 --- a/src/components/settings-i18n.ts +++ b/src/components/settings-i18n.ts @@ -14,4 +14,9 @@ export enum SettingsStringId { FirmwareTitle = 'settings.firmware.title', FirmwareCurrentProgramLabel = 'settings.firmware.flash-current-program.label', FirmwareCurrentProgramTooltip = 'settings.firmware.flash-current-program.tooltip', + HelpTitle = 'settings.help.title', + HelpSupportLabel = 'settings.help.support.label', + HelpChatLabel = 'settings.help.chat.label', + HelpBugsLabel = 'settings.help.bugs.label', + HelpAboutLabel = 'settings.help.about.label', } diff --git a/src/components/settings.scss b/src/components/settings.scss deleted file mode 100644 index a1c23ce4..00000000 --- a/src/components/settings.scss +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors - -// Custom styling for the Settings* controls. - -.pb-settings .bp3-form-group { - margin: 25px; -} - -.pb-settings .bp3-label { - font-size: 18px; - font-weight: bolder; -} - -.pb-settings .bp3-form-helper-text { - font-size: 14px; -} diff --git a/src/components/status-bar.scss b/src/components/status-bar.scss index 60448c17..ea0b6469 100644 --- a/src/components/status-bar.scss +++ b/src/components/status-bar.scss @@ -20,7 +20,7 @@ margin-left: 10px; } -.bp3-progress-bar.status-bar-item { +.#{$ns}-progress-bar.status-bar-item { // override progress bar default gray1 backgound background-color: $pt-app-background-color; } diff --git a/src/components/toolbar.scss b/src/components/toolbar.scss index efe477c7..02a29f4a 100644 --- a/src/components/toolbar.scss +++ b/src/components/toolbar.scss @@ -3,7 +3,9 @@ // Custom styling for the Toolbar control. -.bp3-navbar-divider { +@import '../variables.scss'; + +.#{$ns}-navbar-divider { // don't draw vertical line since we are just using button groups border-left: unset; } diff --git a/src/index.scss b/src/index.scss index b1bab40c..d447317d 100644 --- a/src/index.scss +++ b/src/index.scss @@ -36,3 +36,42 @@ body { .no-box-shadow { box-shadow: unset !important; } + +// global style tweaks + +.#{$ns}-form-group > .#{$ns}-label { + font-weight: bolder; +} + +::-webkit-scrollbar { + width: 16px; +} + +.#{$ns}-dark ::-webkit-scrollbar-track { + background: $pt-dark-app-background-color; +} + +::-webkit-scrollbar-track { + background: $pt-app-background-color; +} + +.#{$ns}-dark ::-webkit-scrollbar-thumb { + border-color: $pt-dark-app-background-color; + background: $pt-dark-icon-color; +} + +::-webkit-scrollbar-thumb { + border-width: 3px; + border-style: solid; + border-radius: 8px; + border-color: $pt-app-background-color; + background: $pt-icon-color; +} + +.#{$ns}-dark ::-webkit-scrollbar-thumb:hover { + background: $pt-dark-icon-color-hover; +} + +::-webkit-scrollbar-thumb:hover { + background: $pt-icon-color-hover; +} diff --git a/src/index.tsx b/src/index.tsx index 28a05b9a..8cb5cf42 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2020-2021 The Pybricks Authors -import { ResizeSensor } from '@blueprintjs/core'; +import { Classes, ResizeSensor } from '@blueprintjs/core'; import { I18nContext, I18nManager } from '@shopify/react-i18n'; import React from 'react'; import ReactDOM from 'react-dom'; @@ -10,7 +10,7 @@ import { applyMiddleware, createStore } from 'redux'; import { createLogger } from 'redux-logger'; import createSagaMiddleware from 'redux-saga'; import './index.scss'; -import { success, update } from './actions/service-worker'; +import { didSucceed, didUpdate } from './actions/service-worker'; import App from './components/App'; import NotificationStack from './components/NotificationStack'; import rootReducer from './reducers'; @@ -38,9 +38,9 @@ store.subscribe(() => { const newDarkMode = store.getState().settings.darkMode; if (newDarkMode !== oldDarkMode) { if (newDarkMode) { - document.body.classList.add('bp3-dark'); + document.body.classList.add(Classes.DARK); } else { - document.body.classList.remove('bp3-dark'); + document.body.classList.remove(Classes.DARK); } oldDarkMode = newDarkMode; } @@ -76,8 +76,8 @@ ReactDOM.render( // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://cra.link/PWA serviceWorkerRegistration.register({ - onUpdate: (r) => store.dispatch(update(r)), - onSuccess: (r) => store.dispatch(success(r)), + onUpdate: (r) => store.dispatch(didUpdate(r)), + onSuccess: (r) => store.dispatch(didSucceed(r)), }); // If you want to start measuring performance in your app, pass a function diff --git a/src/reducers/app.ts b/src/reducers/app.ts index ca68133a..3bcce3ea 100644 --- a/src/reducers/app.ts +++ b/src/reducers/app.ts @@ -7,6 +7,12 @@ import { Reducer, combineReducers } from 'redux'; import { Action } from '../actions'; import { AppActionType } from '../actions/app'; +export interface AppState { + readonly showSettings: boolean; + readonly showAboutDialog: boolean; + readonly showLicenseDialog: boolean; +} + const showSettings: Reducer = (state = false, action) => { switch (action.type) { case AppActionType.OpenSettings: @@ -18,8 +24,26 @@ const showSettings: Reducer = (state = false, action) => { } }; -export interface AppState { - readonly showSettings: boolean; -} +const showAboutDialog: Reducer = (state = false, action) => { + switch (action.type) { + case AppActionType.OpenAboutDialog: + return true; + case AppActionType.CloseAboutDialog: + return false; + default: + return state; + } +}; -export default combineReducers({ showSettings }); +const showLicenseDialog: Reducer = (state = false, action) => { + switch (action.type) { + case AppActionType.OpenLicenseDialog: + return true; + case AppActionType.CloseLicenseDialog: + return false; + default: + return state; + } +}; + +export default combineReducers({ showSettings, showAboutDialog, showLicenseDialog }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index a6ccb937..940b7bf4 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -7,6 +7,7 @@ import ble, { BleState } from './ble'; import bootloader, { BootloaderState } from './bootloader'; import editor, { EditorState } from './editor'; import hub, { HubState } from './hub'; +import license, { LicenseState } from './license'; import notification, { NotificationState } from './notification'; import settings, { SettingsState } from './settings'; import status, { StatusState } from './status'; @@ -21,6 +22,7 @@ export interface RootState { readonly ble: BleState; readonly editor: EditorState; readonly hub: HubState; + readonly license: LicenseState; readonly notification: NotificationState; readonly settings: SettingsState; readonly status: StatusState; @@ -33,6 +35,7 @@ export default combineReducers({ ble, editor, hub, + license, notification, settings, status, diff --git a/src/reducers/license.ts b/src/reducers/license.ts new file mode 100644 index 00000000..cae9e9cd --- /dev/null +++ b/src/reducers/license.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { Reducer, combineReducers } from 'redux'; +import { Action } from '../actions'; +import { LicenseActionType } from '../actions/license'; + +export interface LicenseInfo { + readonly name: string; + readonly version: string; + readonly author: string | undefined; + readonly license: string; + readonly licenseText: string; +} + +export type LicenseList = LicenseInfo[]; + +export interface LicenseState { + readonly list: LicenseList | null; + readonly selected: LicenseInfo | null; +} + +const list: Reducer = (state = null, action) => { + switch (action.type) { + case LicenseActionType.DidFetchList: + return action.list; + default: + return state; + } +}; + +const selected: Reducer = (state = null, action) => { + switch (action.type) { + case LicenseActionType.Select: + return action.info; + default: + return state; + } +}; + +export default combineReducers({ list, selected }); diff --git a/src/reducers/notification.ts b/src/reducers/notification.ts index a2cd255e..690fa1ab 100644 --- a/src/reducers/notification.ts +++ b/src/reducers/notification.ts @@ -147,7 +147,7 @@ const list: Reducer = (state = [], action) => { ]; case NotificationActionType.Remove: return state.filter((e) => e.id !== action.id); - case ServiceWorkerActionType.Update: + case ServiceWorkerActionType.DidUpdate: return append( state, Level.Info, @@ -155,7 +155,7 @@ const list: Reducer = (state = [], action) => { undefined, 'https://github.com/pybricks/pybricks-code/issues/102', ); - case ServiceWorkerActionType.Success: + case ServiceWorkerActionType.DidSucceed: return append(state, Level.Info, MessageId.ServiceWorkerSuccess); default: return state; diff --git a/src/reducers/settings.ts b/src/reducers/settings.ts index 51d4294e..6ae87bc5 100644 --- a/src/reducers/settings.ts +++ b/src/reducers/settings.ts @@ -4,7 +4,7 @@ import { Reducer, combineReducers } from 'redux'; import { Action } from '../actions'; import { SettingsActionType } from '../actions/settings'; -import { SettingId, getDefaultBooleanValue } from '../settings'; +import { SettingId, getDefaultBooleanValue } from '../settings/user'; export interface SettingsState { readonly darkMode: boolean; diff --git a/src/sagas/error-log.test.ts b/src/sagas/error-log.test.ts index 15478f98..b0bd8f25 100644 --- a/src/sagas/error-log.test.ts +++ b/src/sagas/error-log.test.ts @@ -7,6 +7,7 @@ import { didFailToConnect as bleDidFailToConnect, } from '../actions/ble'; import { didFailToWrite } from '../actions/ble-uart'; +import { didFailToFetchList } from '../actions/license'; import { BootloaderConnectionFailureReason, didError, @@ -68,3 +69,17 @@ test('bootloaderDidError', async () => { await saga.end(); }); + +test('licenseDidFailToFetch', async () => { + const saga = new AsyncSaga(errorLog); + + console.error = jest.fn(); + saga.put( + didFailToFetchList( + new Response(undefined, { status: 404, statusText: 'not found' }), + ), + ); + expect(console.error).toHaveBeenCalledTimes(1); + + await saga.end(); +}); diff --git a/src/sagas/error-log.ts b/src/sagas/error-log.ts index 7457c029..d7e96dbe 100644 --- a/src/sagas/error-log.ts +++ b/src/sagas/error-log.ts @@ -8,6 +8,7 @@ import { BleDeviceFailToConnectReasonType, } from '../actions/ble'; import { BleUartActionType, BleUartDidFailToWriteAction } from '../actions/ble-uart'; +import { LicenseActionType, LicenseDidFailToFetchListAction } from '../actions/license'; import { BootloaderConnectionActionType, BootloaderConnectionDidErrorAction, @@ -39,6 +40,10 @@ function bootloaderDidError(action: BootloaderConnectionDidErrorAction): void { console.error(action.err); } +function licenseDidFailToFetch(action: LicenseDidFailToFetchListAction): void { + console.error(`Failed to fetch licenses: ${action.reason.statusText}`); +} + export default function* (): Generator { yield takeEvery(BleDeviceActionType.DidFailToConnect, bleDeviceDidFailToConnect); yield takeEvery(BleUartActionType.DidFailToWrite, bleDataDidFailToWrite); @@ -47,4 +52,5 @@ export default function* (): Generator { bootloaderDidFailToConnect, ); yield takeEvery(BootloaderConnectionActionType.DidError, bootloaderDidError); + yield takeEvery(LicenseActionType.DidFailToFetchList, licenseDidFailToFetch); } diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 24a2cc0c..7682125d 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -8,6 +8,7 @@ import editor from './editor'; import errorLog from './error-log'; import flashFirmware from './flash-firmware'; import hub from './hub'; +import license from './license'; import lwp3BootloaderBle from './lwp3-bootloader-ble'; import lwp3BootloaderProtocol from './lwp3-bootloader-protocol'; import mpy from './mpy'; @@ -24,6 +25,7 @@ export default function* (): Generator { errorLog(), flashFirmware(), hub(), + license(), mpy(), settings(), terminal(), diff --git a/src/sagas/license.test.ts b/src/sagas/license.test.ts new file mode 100644 index 00000000..609072f3 --- /dev/null +++ b/src/sagas/license.test.ts @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2020-2021 The Pybricks Authors + +// Tests for license sagas. + +import { AsyncSaga, delay } from '../../test'; +import { openLicenseDialog } from '../actions/app'; +import { didFailToFetchList, didFetchList } from '../actions/license'; +import { LicenseList, LicenseState } from '../reducers/license'; +import license from './license'; + +afterAll(() => { + jest.restoreAllMocks(); +}); + +describe('fetchLicenses', () => { + test('first call', async () => { + const testLicenseList: LicenseList = []; + const saga = new AsyncSaga(license); + + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(testLicenseList)), + ); + + // initially, license list starts as null, so fetch is called to get + // the list + saga.setState({ license: { list: null } as LicenseState }); + saga.put(openLicenseDialog()); + + const action = await saga.take(); + expect(action).toEqual(didFetchList(testLicenseList)); + + await saga.end(); + }); + test('second call', async () => { + const testLicenseList: LicenseList = []; + const saga = new AsyncSaga(license); + + jest.spyOn(globalThis, 'fetch').mockRejectedValue( + 'fetch () should not have been called', + ); + + // after we have the list, we don't fetch it again since it will + // always be the same list + saga.setState({ license: { list: testLicenseList } as LicenseState }); + saga.put(openLicenseDialog()); + + // have to yield to be sure fetch call would have taken place on error + await delay(0); + + await saga.end(); + }); + test('failed fetch', async () => { + const failResponse = new Response(undefined, { status: 404 }); + const saga = new AsyncSaga(license); + + jest.spyOn(globalThis, 'fetch').mockResolvedValue(failResponse); + + saga.setState({ license: { list: null } as LicenseState }); + saga.put(openLicenseDialog()); + + const action = await saga.take(); + expect(action).toEqual(didFailToFetchList(failResponse)); + + await saga.end(); + }); +}); diff --git a/src/sagas/license.ts b/src/sagas/license.ts new file mode 100644 index 00000000..0bd1eac0 --- /dev/null +++ b/src/sagas/license.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +import { call, put, select, takeEvery } from 'redux-saga/effects'; +import { AppActionType } from '../actions/app'; +import { didFailToFetchList, didFetchList } from '../actions/license'; +import { RootState } from '../reducers'; +import { LicenseList } from '../reducers/license'; + +function* fetchLicenses(): Generator { + const licenses = (yield select( + (s: RootState) => s.license.list, + )) as LicenseList | null; + + // if we already have license list, nothing to do + if (licenses !== null) { + return; + } + + const response = (yield call(() => fetch('static/oss-licenses.json'))) as Response; + if (!response.ok || response.body === null) { + yield put(didFailToFetchList(response)); + return; + } + + const list = (yield call(() => response.json())) as LicenseList; + yield put(didFetchList(list)); +} + +export default function* (): Generator { + yield takeEvery(AppActionType.OpenLicenseDialog, fetchLicenses); +} diff --git a/src/sagas/settings.test.ts b/src/sagas/settings.test.ts index ad4c762a..5875b79f 100644 --- a/src/sagas/settings.test.ts +++ b/src/sagas/settings.test.ts @@ -7,7 +7,7 @@ import { AsyncSaga } from '../../test'; import { startup } from '../actions/app'; import { didBooleanChange, didFailToSetBoolean, setBoolean } from '../actions/settings'; import { SettingsState } from '../reducers/settings'; -import { SettingId } from '../settings'; +import { SettingId } from '../settings/user'; import settings from './settings'; afterAll(() => { diff --git a/src/sagas/settings.ts b/src/sagas/settings.ts index 1cbf66d5..4a6dc695 100644 --- a/src/sagas/settings.ts +++ b/src/sagas/settings.ts @@ -15,7 +15,7 @@ import { didFailToSetBoolean, } from '../actions/settings'; import { RootState } from '../reducers'; -import { SettingId, getDefaultBooleanValue } from '../settings'; +import { SettingId, getDefaultBooleanValue } from '../settings/user'; function stringToBoolean(value: string): boolean { return value.toLowerCase().match(/(true|yes|1)/) !== null; diff --git a/src/settings/ui.ts b/src/settings/ui.ts new file mode 100644 index 00000000..4ae33657 --- /dev/null +++ b/src/settings/ui.ts @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2021 The Pybricks Authors + +// Definitions for compile-time UI settings. + +/** Time in milliseconds before showing tooltip popover. */ +export const tooltipDelay = 1000; + +/** Official name of the app. */ +export const appName = 'Pybricks Code'; + +/** URL to main Pybricks website. */ +export const pybricksWebsiteUrl = 'https://pybricks.com'; + +/** URL to Pybricks support site. */ +export const pybricksSupportUrl = 'https://github.com/pybricks/support/discussions'; + +/** URL to Pybricks bug report site. */ +export const pybricksBugReportsUrl = 'https://github.com/pybricks/support/issues'; + +/** URL for Pybricks community chat on Gitter */ +export const pybricksGitterUrl = 'https://gitter.im/pybricks/community'; + +/** Pybricks copyright statement. */ +export const pybricksCopyright = 'Copyright (c) 2020-2021 The Pybricks Authors'; + +// https://www.lego.com/en-us/legal/notices-and-policies/fair-play/ +/** LEGO "fair play" disclaimer */ +export const legoDisclaimer = + 'LEGO® is a trademark of the LEGO Group of companies which does not sponsor, authorize or endorse this site.'; diff --git a/src/settings/index.ts b/src/settings/user.ts similarity index 82% rename from src/settings/index.ts rename to src/settings/user.ts index 0c9a2406..0066ddff 100644 --- a/src/settings/index.ts +++ b/src/settings/user.ts @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -// Definitions for user settings. +// Definitions for user selectable settings. + +import { prefersDarkMode } from '../utils/os'; export enum SettingId { ShowDocs = 'showDocs', @@ -14,6 +16,7 @@ export function getDefaultBooleanValue(id: SettingId): boolean { case SettingId.ShowDocs: return window.innerWidth >= 1024; case SettingId.DarkMode: + return prefersDarkMode(); case SettingId.FlashCurrentProgram: return false; // istanbul ignore next: it is a programmer error if we hit this diff --git a/src/utils/os.test.ts b/src/utils/os.test.ts index 665f08d6..9051252f 100644 --- a/src/utils/os.test.ts +++ b/src/utils/os.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2021 The Pybricks Authors -import { isMacOS } from './os'; +import { isMacOS, prefersDarkMode } from './os'; describe('isMacOS', () => { test('is true', () => { @@ -13,3 +13,22 @@ describe('isMacOS', () => { expect(isMacOS()).toBeFalsy(); }); }); + +describe('prefersDarkMode', () => { + test('is true', () => { + window.matchMedia = jest.fn().mockReturnValue({ + matches: true, + } as MediaQueryList); + expect(prefersDarkMode()).toBeTruthy(); + }); + test('is false', () => { + // @ts-expect-error 2790 + delete window.matchMedia; + expect(prefersDarkMode()).toBeFalsy(); + + window.matchMedia = jest.fn().mockReturnValue({ + matches: false, + } as MediaQueryList); + expect(prefersDarkMode()).toBeFalsy(); + }); +}); diff --git a/src/utils/os.ts b/src/utils/os.ts index 44a11446..e32d5471 100644 --- a/src/utils/os.ts +++ b/src/utils/os.ts @@ -3,6 +3,22 @@ // Utility functions for dealing with operating systems. +/** + * Tests if we are running on macOS. + * @returns `true` if running on macOS, otherwise `false`. + */ export function isMacOS(): boolean { return /mac/i.test(navigator.platform); } + +/** + * Tests if the OS is set to dark mode. + * @returns: `true` if dark mode should be preferred, otherwise `false`. + */ +export function prefersDarkMode(): boolean { + if (!window.matchMedia) { + return false; + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/src/variables.scss b/src/variables.scss index 3005c042..17189444 100644 --- a/src/variables.scss +++ b/src/variables.scss @@ -6,6 +6,10 @@ // override variables here // See https://blueprintjs.com/docs/#core/variables +$pt-font-size: $pt-grid-size * 1.6; +$pt-font-size-large: $pt-grid-size * 1.8; +$pt-font-size-small: $pt-grid-size * 1.4; + $pt-navbar-height: 72px; $pb-status-bar-height: 3vh; diff --git a/yarn.lock b/yarn.lock index aedc6180..dd1f9da2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2101,6 +2101,15 @@ "@types/source-list-map" "*" source-map "^0.7.3" +"@types/webpack-sources@^0.1.5": + version "0.1.8" + resolved "https://registry.yarnpkg.com/@types/webpack-sources/-/webpack-sources-0.1.8.tgz#078d75410435993ec8a0a2855e88706f3f751f81" + integrity sha512-JHB2/xZlXOjzjBB6fMOpH1eQAfsrpqVVIbneE0Rok16WXwFaznaI5vfg75U5WgGJm7V9W1c4xeRQDjX/zwvghA== + dependencies: + "@types/node" "*" + "@types/source-list-map" "*" + source-map "^0.6.1" + "@types/webpack@^4.41.8": version "4.41.25" resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.25.tgz#4d3b5aecc4e44117b376280fbfd2dc36697968c4" @@ -2637,7 +2646,7 @@ arr-union@^3.1.0: resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= -array-find-index@^1.0.1: +array-find-index@^1.0.1, array-find-index@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1" integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E= @@ -7601,6 +7610,14 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +license-webpack-plugin@^2.3.11: + version "2.3.11" + resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.3.11.tgz#0d93188a31fce350a44c86212badbaf33dcd29d8" + integrity sha512-0iVGoX5vx0WDy8dmwTTpOOMYiGqILyUbDeVMFH52AjgBlS58lHwOlFMSoqg5nY8Kxl6+FRKyUZY/UdlQaOyqDw== + dependencies: + "@types/webpack-sources" "^0.1.5" + webpack-sources "^1.2.0" + lie@~3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" @@ -11144,6 +11161,15 @@ sourcemap-codec@^1.4.4: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== +spdx-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/spdx-compare/-/spdx-compare-1.0.0.tgz#2c55f117362078d7409e6d7b08ce70a857cd3ed7" + integrity sha512-C1mDZOX0hnu0ep9dfmuoi03+eOdDoz2yvK79RxbcrVEG1NO1Ph35yW102DHWKN4pk80nwCgeMmSY5L25VE4D9A== + dependencies: + array-find-index "^1.0.2" + spdx-expression-parse "^3.0.0" + spdx-ranges "^2.0.0" + spdx-correct@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" @@ -11170,6 +11196,20 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== +spdx-ranges@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/spdx-ranges/-/spdx-ranges-2.1.1.tgz#87573927ba51e92b3f4550ab60bfc83dd07bac20" + integrity sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA== + +spdx-satisfies@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/spdx-satisfies/-/spdx-satisfies-5.0.0.tgz#d740b8f14caeada36fb307629dee87146970a256" + integrity sha512-/hGhwh20BeGmkA+P/lm06RvXD94JduwNxtx/oX3B5ClPt1/u/m5MCaDNo1tV3Y9laLkQr/NRde63b9lLMhlNfw== + dependencies: + spdx-compare "^1.0.0" + spdx-expression-parse "^3.0.0" + spdx-ranges "^2.0.0" + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" @@ -12418,7 +12458,7 @@ webpack-merge@^4.2.2: dependencies: lodash "^4.17.15" -webpack-sources@^1.1.0, webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: +webpack-sources@^1.1.0, webpack-sources@^1.2.0, webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==