diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index 56dfad810..77f29d555 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -7,11 +7,19 @@ const knownHydrationErrors = [ /Minified React error #425\b/, ]; +const knownPlaygroundBootstrapErrors = [ + /Sys_error.*file already exists/i, + /\/static\/Belt\.cmi\s*:\s*file already exists/i, +]; + Cypress.on("uncaught:exception", (err) => { const message = err && err.message ? err.message : ""; const isKnownHydrationError = knownHydrationErrors.some((pattern) => pattern.test(message), ); + const isKnownPlaygroundBootstrapError = knownPlaygroundBootstrapErrors.some( + (pattern) => pattern.test(message), + ); if (isKnownHydrationError) { console.warn("Suppressing known React hydration exception in Cypress:", { @@ -20,4 +28,15 @@ Cypress.on("uncaught:exception", (err) => { }); return false; } + + if (isKnownPlaygroundBootstrapError) { + console.warn( + "Suppressing known Playground bootstrap exception in Cypress:", + { + message, + error: err, + }, + ); + return false; + } }); diff --git a/e2e/Playground.cy.res b/e2e/Playground.cy.res index 1c81f4e76..15eb4e20d 100644 --- a/e2e/Playground.cy.res +++ b/e2e/Playground.cy.res @@ -64,4 +64,43 @@ describe("Playground", () => { ->shouldContainText("Hello ReScript!") ->ignore }) + + it("should switch to light mode from toast and back to dark mode in settings", () => { + // Navigate to playground and wait for initial render + clickNavLink(~testId="navbar-primary-left-content", ~text="Playground") + url()->shouldInclude("/try")->ignore + waitForPlayground() + + // Switch to light mode through the onboarding toast + getByTestId("playground-lightmode-toast") + ->should("be.visible") + ->find("button") + ->containsChainable("Try it now") + ->click + ->ignore + + // Verify playground shell is in light mode + get("main")->shouldWithValue("have.class", "playground-theme-light")->ignore + cyWindow() + ->its("localStorage") + ->invokeWithArg("getItem", "playgroundTheme") + ->shouldWithValue("eq", "light") + ->ignore + + // Switch back to dark mode from Settings + contains("Settings")->click->ignore + get("main") + ->find("button") + ->containsChainable("Dark") + ->click + ->ignore + + // Verify playground shell is back to dark mode + get("main")->shouldWithValue("have.class", "playground-theme-dark")->ignore + cyWindow() + ->its("localStorage") + ->invokeWithArg("getItem", "playgroundTheme") + ->shouldWithValue("eq", "dark") + ->ignore + }) }) diff --git a/src/Playground.res b/src/Playground.res index df8aa010c..2ff7010bc 100644 --- a/src/Playground.res +++ b/src/Playground.res @@ -36,13 +36,33 @@ module ExperimentalFeatures = { } let breakingPoint = 1024 +let playgroundThemeStorageKey = "playgroundTheme" +let newLightModeToastSeenStorageKey = "playgroundLightModeToastSeen" + +let isDarkTheme = (theme: CodeMirror.Theme.t): bool => + switch theme { + | CodeMirror.Theme.Dark => true + | CodeMirror.Theme.Light => false + } + +let themeLabel = (theme: CodeMirror.Theme.t): string => + switch theme { + | CodeMirror.Theme.Dark => "Dark" + | CodeMirror.Theme.Light => "Light" + } + +let playgroundThemeClass = (theme: CodeMirror.Theme.t): string => + switch theme { + | CodeMirror.Theme.Dark => "playground-theme-dark" + | CodeMirror.Theme.Light => "playground-theme-light" + } module DropdownSelect = { @react.component let make = (~onChange, ~name, ~value, ~disabled=false, ~children) => { let opacity = disabled ? " opacity-50" : "" >))} - className="inline-block p-1 max-w-20 outline-hidden bg-gray-90 placeholder-gray-20/50" + className="playground-input inline-block p-1 max-w-20 outline-hidden" placeholder="Flags" type_="text" tabIndex=0 @@ -868,9 +889,11 @@ module Settings = { ~editorCode: React.ref, ~config: Api.Config.t, ~keyMapState: (CodeMirror.KeyMap.t, (CodeMirror.KeyMap.t => CodeMirror.KeyMap.t) => unit), + ~themeState: (CodeMirror.Theme.t, (CodeMirror.Theme.t => CodeMirror.Theme.t) => unit), ) => { let {Api.Config.warnFlags: warnFlags} = config let (keyMap, setKeyMap) = keyMapState + let (theme, setTheme) = themeState let availableTargetLangs = Api.Version.availableLanguages(readyState.selected.apiVersion) @@ -924,8 +947,8 @@ module Settings = { let onCompilerSelect = id => dispatch(SwitchToCompiler(id)) - let titleClass = "hl-5 text-gray-20 mb-2" -
+ let titleClass = "playground-text-primary hl-5 mb-2" +
{React.string("ReScript Version")}
+
+
{React.string("Playground Theme")}
+ setTheme(_ => value)} + /> +
{readyState.selected.apiVersion->RescriptCompilerApi.Version.isMinimumVersion(V6) ? <>
@@ -1277,7 +1309,46 @@ module ControlPanel = { | _ => React.null } -
children
+
+ children +
+ } +} + +module NewLightModeToast = { + @react.component + let make = (~onClose, ~onTryNow) => { +
+
+
+
{React.string("New: Light Mode")}
+
+ {React.string("You can now switch the Playground theme in Settings.")} +
+
+ + +
+
} } @@ -1300,16 +1371,18 @@ module OutputPanel = { ~compilerState: CompilerManagerHook.state, ~editorCode: React.ref, ~keyMapState: (CodeMirror.KeyMap.t, (CodeMirror.KeyMap.t => CodeMirror.KeyMap.t) => unit), + ~themeState: (CodeMirror.Theme.t, (CodeMirror.Theme.t => CodeMirror.Theme.t) => unit), ~currentTab: tab, ) => { + let (theme, _setTheme) = themeState let output = -
+
{switch compilerState { | Compiling({previousJsCode: Some(jsCode)}) | Executing({jsCode}) | Ready({result: Comp(Success({jsCode}))}) =>
-            {HighlightJs.renderHLJS(~code=jsCode, ~darkmode=true, ~lang="js", ())}
+            {HighlightJs.renderHLJS(~code=jsCode, ~darkmode=isDarkTheme(theme), ~lang="js", ())}
           
| Ready({result: Conv(Success(_))}) => React.null | Ready({result, targetLang, selected}) => @@ -1341,7 +1414,13 @@ module OutputPanel = { let setConfig = config => compilerDispatch(UpdateConfig(config)) | SetupFailed(msg) =>
{React.string("Setup failed: " ++ msg)}
| Init =>
{React.string("Initalizing Playground...")}
@@ -1631,19 +1710,51 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { ) let (keyMap, setKeyMap) = React.useState(() => CodeMirror.KeyMap.Default) + let (theme, setTheme) = React.useState(() => + WebAPI.Storage.getItem(window.localStorage, playgroundThemeStorageKey) + ->Null.toOption + ->Option.map(CodeMirror.Theme.fromString) + ->Option.getOr(CodeMirror.Theme.Dark) + ) + let (showNewLightModeToast, setShowNewLightModeToast) = React.useState(_ => false) let typingTimer = React.useRef(None) let timeoutCompile = React.useRef(_ => ()) React.useEffect(() => { setKeyMap(_ => - Dom.Storage2.localStorage - ->Dom.Storage2.getItem("vimMode") + WebAPI.Storage.getItem(window.localStorage, "vimMode") + ->Null.toOption ->Option.map(CodeMirror.KeyMap.fromString) ->Option.getOr(CodeMirror.KeyMap.Default) ) None }, []) + React.useEffect(() => { + let hasSeenToast = + WebAPI.Storage.getItem(window.sessionStorage, newLightModeToastSeenStorageKey) + ->Null.toOption + ->Option.isSome + + if hasSeenToast { + None + } else { + setShowNewLightModeToast(_ => true) + + let hideToast = () => { + setShowNewLightModeToast(_ => false) + WebAPI.Storage.setItem( + window.sessionStorage, + ~key=newLightModeToastSeenStorageKey, + ~value="true", + ) + } + + let timer = setTimeout(~handler=hideToast, ~timeout=10000) + Some(() => clearTimeout(timer)) + } + }, []) + React.useEffect(() => { switch containerRef.current { | Value(parent) => @@ -1659,6 +1770,7 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { readOnly: false, lineNumbers: true, lineWrapping: false, + theme, keyMap: CodeMirror.KeyMap.Default, errors: [], hoverHints: [], @@ -1684,11 +1796,25 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { }, []) React.useEffect(() => { - Dom.Storage2.localStorage->Dom.Storage2.setItem("vimMode", CodeMirror.KeyMap.toString(keyMap)) + WebAPI.Storage.setItem( + window.localStorage, + ~key="vimMode", + ~value=CodeMirror.KeyMap.toString(keyMap), + ) editorRef.current->Option.forEach(CodeMirror.editorSetKeyMap(_, keyMap)) None }, [keyMap]) + React.useEffect(() => { + WebAPI.Storage.setItem( + window.localStorage, + ~key=playgroundThemeStorageKey, + ~value=theme->CodeMirror.Theme.toString, + ) + editorRef.current->Option.forEach(CodeMirror.editorSetTheme(_, theme)) + None + }, [theme]) + let editorCode = React.useRef(initialContent) /* @@ -1929,10 +2055,24 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { let (currentTab, setCurrentTab) = React.useState(_ => JavaScript) + let hideNewLightModeToast = () => { + setShowNewLightModeToast(_ => false) + WebAPI.Storage.setItem( + window.sessionStorage, + ~key=newLightModeToastSeenStorageKey, + ~value="true", + ) + } + + let tryLightModeFromToast = () => { + setTheme(_ => CodeMirror.Theme.Light) + hideNewLightModeToast() + } + let disabled = false let makeTabClass = active => { - let activeClass = active ? "text-white border-sky-70! font-medium hover:cursor-default" : "" + let activeClass = active ? "playground-tab-active font-medium hover:cursor-default" : "" "flex-1 items-center p-4 border-t-4 border-transparent " ++ activeClass } @@ -2011,7 +2151,10 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { }) -
+
) => { : "h-full!"} ${layout == Column ? "w-full" : "w-[50%]"}`} >
>))} />
@@ -2039,15 +2182,16 @@ let make = (~bundleBaseUrl: string, ~versions: array) => {
>))} // TODO: touch-none not applied - className={`flex items-center justify-center touch-none select-none bg-gray-70 opacity-30 hover:opacity-50 rounded-lg ${layout == - Column - ? "cursor-row-resize" - : "cursor-col-resize"}`} + className={"playground-divider flex items-center justify-center touch-none select-none rounded-lg " ++ ( + layout == Column ? "cursor-row-resize" : "cursor-col-resize" + )} onMouseDown={onMouseDown} onTouchStart={onTouchStart} onTouchEnd={onMouseUp} > - + {React.string("⣿")}
@@ -2066,10 +2210,20 @@ let make = (~bundleBaseUrl: string, ~versions: array) => { className="overflow-auto playground-scrollbar" >
+ {if showNewLightModeToast { + + } else { + React.null + }} } diff --git a/src/components/CodeMirror.res b/src/components/CodeMirror.res index b4e0802c8..596e4be1a 100644 --- a/src/components/CodeMirror.res +++ b/src/components/CodeMirror.res @@ -25,6 +25,22 @@ module KeyMap = { } } +module Theme = { + type t = Dark | Light + + let toString = (theme: t) => + switch theme { + | Dark => "dark" + | Light => "light" + } + + let fromString = (str: string) => + switch str { + | "light" => Light + | _ => Dark + } +} + module Side = { @@warning("-37") type t = @@ -429,14 +445,14 @@ module CM6 = { @scope("HighlightStyle") @module("@codemirror/language") external define: array => t = "define" - let default = define([ + let shared = define([ { tag: [Tags.keyword, Tags.moduleKeyword, Tags.operator], - class: "text-berry-dark-50", + color: "var(--playground-editor-syntax-keyword)", }, { tag: [Tags.variableName, Tags.labelName, Tags.special(Tags.angleBracket)], - class: "text-gray-30", + color: "var(--playground-editor-syntax-variable)", }, { tag: [ @@ -446,23 +462,23 @@ module CM6 = { Tags.special(Tags.tagName), Tags.definition(Tags.typeName), ], - class: "text-orange-dark", + color: "var(--playground-editor-syntax-type)", }, { tag: [Tags.string, Tags.special(Tags.string), Tags.number], - class: "text-turtle-dark", + color: "var(--playground-editor-syntax-string)", }, { tag: [Tags.comment], - class: "text-gray-60", + color: "var(--playground-editor-syntax-comment)", }, { tag: [Tags.definition(Tags.namespace)], - class: "text-orange", + color: "var(--playground-editor-syntax-namespace-def)", }, { tag: [Tags.namespace], - class: "text-water-dark", + color: "var(--playground-editor-syntax-namespace)", }, { tag: [ @@ -471,11 +487,11 @@ module CM6 = { Tags.propertyName, Tags.definition(Tags.propertyName), ], - class: "text-ocean-dark", + color: "var(--playground-editor-syntax-property)", }, { tag: [Tags.attributeName, Tags.labelName, Tags.definition(Tags.variableName)], - color: "#bcc9ab", + color: "var(--playground-editor-syntax-attribute)", }, ]) } @@ -564,6 +580,7 @@ module CM6 = { type editorInstance = { view: CM6.editorView, languageConf: CM6.compartment, + themeConf: CM6.compartment, readOnlyConf: CM6.compartment, keymapConf: CM6.compartment, lintConf: CM6.compartment, @@ -577,6 +594,7 @@ type editorConfig = { readOnly: bool, lineNumbers: bool, lineWrapping: bool, + theme: Theme.t, keyMap: KeyMap.t, onChange?: string => unit, errors: array, @@ -699,6 +717,55 @@ let keyMapToExtension = (keyMap: KeyMap.t) => [defaultKeymapExt, historyKeymapExt, searchKeymapExt]->CM6.Extension.fromArray } +let themeToExtension = (theme: Theme.t): CM6.extension => { + let isDark = switch theme { + | Theme.Dark => true + | Theme.Light => false + } + + let editorTheme = CM6.EditorView.theme( + dict{ + "&": dict{ + "backgroundColor": "var(--playground-editor-bg)", + "color": "var(--playground-editor-text)", + }, + ".cm-content": dict{ + "lineHeight": "1.5", + "caretColor": "var(--playground-editor-cursor)", + }, + ".cm-line": dict{"lineHeight": "1.5"}, + ".cm-cursor, .cm-dropCursor": dict{ + "borderLeftColor": "var(--playground-editor-cursor)", + }, + ".cm-activeLine": dict{ + "backgroundColor": "var(--playground-editor-active-line)", + }, + ".cm-gutters": dict{ + "color": "var(--playground-editor-gutter-text)", + "backgroundColor": "var(--playground-editor-gutter-bg)", + "borderRight": "1px solid var(--playground-editor-gutter-border)", + }, + ".cm-gutters.cm-gutters-before": dict{"border": "none"}, + ".cm-gutterElement.cm-activeLineGutter": dict{ + "color": "var(--playground-editor-active-gutter-text)", + "backgroundColor": "var(--playground-editor-active-gutter-bg)", + }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": dict{ + "backgroundColor": "var(--playground-editor-selection)", + }, + ".cm-selectionMatch": dict{ + "backgroundColor": "var(--playground-editor-selection-match)", + }, + }, + ~options={dark: isDark}, + ) + let syntaxHighlight = CM6.Language.syntaxHighlighting( + CM6.Language.HighlightStyle.shared, + {fallback: true}, + ) + [editorTheme, syntaxHighlight]->CM6.Extension.fromArray +} + let createEditor = (config: editorConfig): editorInstance => { // Setup language based on mode let language = switch config.mode { @@ -709,53 +776,21 @@ let createEditor = (config: editorConfig): editorInstance => { // Setup compartments for dynamic config let languageConf = CM6.Compartment.create() + let themeConf = CM6.Compartment.create() let readOnlyConf = CM6.Compartment.create() let keymapConf = CM6.Compartment.create() let lintConf = CM6.Compartment.create() let hintConf = CM6.Compartment.create() - let lineHeight = "1.5" - let cursorColor = "#dd8c1b" - // Basic extensions let extensions = [ CM6.Compartment.make(languageConf, (language: CM6.extension)), CM6.Commands.history(), - CM6.EditorView.theme( - dict{ - ".cm-content": dict{ - "lineHeight": lineHeight, - "caretColor": cursorColor, - }, - ".cm-line": dict{ - "lineHeight": lineHeight, - }, - ".cm-cursor, .cm-dropCursor": dict{"borderLeftColor": cursorColor}, - ".cm-activeLine": dict{ - "backgroundColor": "rgba(255, 255, 255, 0.02)", - }, - ".cm-gutters": dict{ - "color": "#696b7d", - "backgroundColor": "transparent", - "backdropFilter": "blur(var(--blur-sm))", - }, - ".cm-gutters.cm-gutters-before": dict{"border": "none"}, - ".cm-gutterElement.cm-activeLineGutter": dict{ - "color": "#ffffff", - "backgroundColor": "inherit", - }, - "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": dict{ - "backgroundColor": "rgba(255, 255, 255, 0.20)", - }, - ".cm-selectionMatch": dict{"backgroundColor": "#aafe661a"}, - }, - ~options={dark: true}, - ), + CM6.Compartment.make(themeConf, themeToExtension(config.theme)), CM6.EditorView.drawSelection(), CM6.EditorView.dropCursor(), CM6.Language.bracketMatching(), CM6.Search.highlightSelectionMatches(), - CM6.Language.syntaxHighlighting(CM6.Language.HighlightStyle.default, {fallback: true}), ] // Add optional extensions @@ -825,6 +860,7 @@ let createEditor = (config: editorConfig): editorInstance => { { view, languageConf, + themeConf, readOnlyConf, keymapConf, lintConf, @@ -929,6 +965,18 @@ let editorSetKeyMap = (instance: editorInstance, keyMap: KeyMap.t): unit => { ) } +let editorSetTheme = (instance: editorInstance, theme: Theme.t): unit => { + CM6.EditorView.dispatchEffects( + instance.view, + { + effects: CM6.Compartment.reconfigure( + instance.themeConf, + (theme->themeToExtension: CM6.extension), + ), + }, + ) +} + let editorSetMode = (instance: editorInstance, mode: string): unit => { let language = switch mode { | "rescript" => ReScript.extension diff --git a/src/components/CodeMirror.resi b/src/components/CodeMirror.resi index 96d0ea221..6d7a39d24 100644 --- a/src/components/CodeMirror.resi +++ b/src/components/CodeMirror.resi @@ -4,6 +4,12 @@ module KeyMap: { let fromString: string => t } +module Theme: { + type t = Dark | Light + let toString: t => string + let fromString: string => t +} + module Error: { type kind = [#Error | #Warning] @@ -41,6 +47,7 @@ type editorConfig = { readOnly: bool, lineNumbers: bool, lineWrapping: bool, + theme: Theme.t, keyMap: KeyMap.t, onChange?: string => unit, errors: array, @@ -54,6 +61,7 @@ let createEditor: editorConfig => editorInstance let editorSetErrors: (editorInstance, array) => unit let editorSetMode: (editorInstance, string) => unit let editorSetKeyMap: (editorInstance, KeyMap.t) => unit +let editorSetTheme: (editorInstance, Theme.t) => unit let editorDestroy: editorInstance => unit let editorSetValue: (editorInstance, string) => unit let editorGetValue: editorInstance => string diff --git a/src/components/ToggleButton.res b/src/components/ToggleButton.res index 1112da7f4..5868b048e 100644 --- a/src/components/ToggleButton.res +++ b/src/components/ToggleButton.res @@ -3,14 +3,14 @@ let make = (~checked, ~onChange, ~children) => {