diff --git a/.gitignore b/.gitignore index d5f19d8..46096f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules package-lock.json +.DS_Store diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/client/.gitignore @@ -0,0 +1 @@ +/build diff --git a/client/.prettierignore b/client/.prettierignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/client/.prettierignore @@ -0,0 +1 @@ +/build diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..f4d5d8d --- /dev/null +++ b/client/package.json @@ -0,0 +1,52 @@ +{ + "name": "cyf-blocks-client", + "version": "0.1.0", + "private": true, + "dependencies": { + "@blockly/theme-highcontrast": "^2.0.3", + "@blockly/theme-modern": "^2.1.28", + "@testing-library/jest-dom": "^5.16.2", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^13.5.0", + "blockly": "^7.20211209.4", + "lodash": "^4.17.21", + "prism-react-renderer": "^1.3.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-markdown": "^8.0.0", + "react-scripts": "5.0.0", + "react-split-grid": "^1.0.4", + "sass": "^1.45.1", + "use-deep-compare-effect": "^1.8.1", + "web-vitals": "^2.1.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject", + "format": "prettier . --check", + "format:fix": "prettier . --write" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "prettier": "^2.6.2" + } +} diff --git a/client/public/index.html b/client/public/index.html new file mode 100644 index 0000000..f0f68c9 --- /dev/null +++ b/client/public/index.html @@ -0,0 +1,12 @@ + + + + + + CYF Blocks + + + +
+ + diff --git a/client/src/App.js b/client/src/App.js new file mode 100644 index 0000000..ae8ace5 --- /dev/null +++ b/client/src/App.js @@ -0,0 +1,124 @@ +import { useState } from "react"; +import * as Blockly from "blockly/core"; +import locale from "blockly/msg/en"; +import "blockly/blocks"; + +import "./Blocks/dom"; +import "./Blocks/cyf"; +import useBlockly from "./Blockly/useBlockly"; + +import * as Exercise1 from "./Exercises/01-stuff"; +import * as Exercise2 from "./Exercises/02-more-stuff"; + +import Split from "react-split-grid"; +import TextPanel from "./TextPanel/TextPanel"; +import Output from "./Output/Output"; +import Header from "./Layout/Header/Header"; +import Menu from "./Layout/Menu/Menu"; +import Footer from "./Layout/Footer/Footer"; +import Button from "./Button/Button"; +import "./App.scss"; + +import { ReactComponent as Background } from "../src/svgs/Humaaans-Phone.svg"; + +Blockly.setLocale(locale); + +// just left all this and presumed you will pass whatever you decide to do into the text panel +const exercises = [Exercise1, Exercise2]; + +function useExercise() { + const [exerciseIndex, setExerciseIndex] = useState(0); + + function nextExercise() { + setExerciseIndex(exerciseIndex + 1); + } + function prevExercise() { + setExerciseIndex(exerciseIndex - 1); + } + + return { + exercise: exercises[exerciseIndex], + hasNextExercise: exerciseIndex + 1 < exercises.length, + nextExercise, + hasPrevExercise: exerciseIndex - 1 >= 0, + prevExercise, + }; +} + +export default function App() { + const { + exercise, + hasNextExercise, + nextExercise, + hasPrevExercise, + prevExercise, + } = useExercise(); + + const { BlocklyComponent, generate } = useBlockly({ + toolbox: exercise.toolbox, + }); + + const [generated, setGenerated] = useState(""); + + function handleGenerate() { + setGenerated(generate()); + } + // these are resizable panels -- a possible solution to the problem of making all three things available + // so the user can compare across text, blocks, and output + // not married to this + + return ( +
+ +
+ + ( +
+ +
+ +
+ +
+ )} + /> + +
+ ); +} diff --git a/client/src/App.scss b/client/src/App.scss new file mode 100644 index 0000000..ce26725 --- /dev/null +++ b/client/src/App.scss @@ -0,0 +1,20 @@ +@import "theme/utilities.scss"; + +.c-layout { + @include grid-assign(header, panels, footer); + grid-template-columns: 0 1fr 0; + grid-template-areas: + ". ...... ." + ". header . " + ". panels ." + ". footer ."; + gap: var(--theme-spacing--4); + place-content: center; + max-width: 100vw; + overflow-y: hidden; + + &__panels { + display: grid; + grid-template-columns: 2fr 48px 1fr 48px 6fr; + } +} diff --git a/client/src/Blockly/Blockly.scss b/client/src/Blockly/Blockly.scss new file mode 100644 index 0000000..7c6aaf6 --- /dev/null +++ b/client/src/Blockly/Blockly.scss @@ -0,0 +1,5 @@ +@import "../theme/utilities.scss"; + +.c-blockly { + height: 100%; +} diff --git a/client/src/Blockly/CYFTheme.js b/client/src/Blockly/CYFTheme.js new file mode 100644 index 0000000..80ae21f --- /dev/null +++ b/client/src/Blockly/CYFTheme.js @@ -0,0 +1,95 @@ +/** + * @fileoverview Higher contrast, brandable theme. + * + */ + +import Blockly from "blockly/core"; + +const defaultBlockStyles = { + colour_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "var(--theme-color--accent)", + colourTertiary: "var(--theme-color--pop)", + }, + list_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#AD7BE9", + colourTertiary: "#CDB6E9", + }, + logic_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#64C7FF", + colourTertiary: "#C5EAFF", + }, + loop_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#9AFF78", + colourTertiary: "#E1FFD7", + }, + math_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#8A9EFF", + colourTertiary: "#DCE2FF", + }, + procedure_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#77E6EE", + colourTertiary: "#CFECEE", + }, + text_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#5ae27c", + colourTertiary: "#D2FFDD", + }, + variable_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#FF73BE", + colourTertiary: "#FFD4EB", + }, + variable_dynamic_blocks: { + colourPrimary: "var(--theme-color--brand)", + colourSecondary: "#FF73BE", + colourTertiary: "#FFD4EB", + }, + hat_blocks: { + colourPrimary: "#880e4f", + colourSecondary: "#FF73BE", + colourTertiary: "#FFD4EB", + hat: "cap", + }, +}; + +const categoryStyles = { + colour_category: { colour: "var(--theme-color--brand)" }, + list_category: { colour: "var(--theme-color--brand)" }, + logic_category: { colour: "var(--theme-color--brand)" }, + loop_category: { colour: "var(--theme-color--brand)" }, + math_category: { colour: "var(--theme-color--brand)" }, + procedure_category: { colour: "var(--theme-color--brand)" }, + text_category: { colour: "var(--theme-color--brand)" }, + variable_category: { colour: "var(--theme-color--brand)" }, + variable_dynamic_category: { colour: "var(--theme-color--brand)" }, +}; + +// Temporarily required to ensure there's no conflict with +// Blockly.Themes.HighContrast +// Blockly.registry.unregister("theme", "highcontrast"); + +/** + * High contrast theme. + */ +export default Blockly.Theme.defineTheme("highcontrast", { + blockStyles: defaultBlockStyles, + categoryStyles: categoryStyles, + componentStyles: { + selectedGlowColour: "#000000", + selectedGlowSize: 1, + replacementGlowColour: "#000000", + }, + fontStyle: { + family: "var(--theme-font-display)", // Use default font-family. + weight: null, // Use default font-weight. + size: 16, + }, + startHats: null, +}); diff --git a/client/src/Blockly/useBlockly.js b/client/src/Blockly/useBlockly.js new file mode 100644 index 0000000..b06466e --- /dev/null +++ b/client/src/Blockly/useBlockly.js @@ -0,0 +1,56 @@ +import { useMemo, useRef } from "react"; +import useDeepCompareEffect from "use-deep-compare-effect"; +import _ from "lodash"; +import * as Blockly from "blockly/core"; +import BlocklyJS from "blockly/javascript"; +import "./Blockly.scss"; +import * as Theme from "./CYFTheme"; + +export default function useBlockly({ initialBlock, toolbox, theme }) { + const wrapperRef = useRef(); + const workspaceRef = useRef(); + + // Since the deps are objects, we need to deep compare them + useDeepCompareEffect(() => { + // Inject the workspace + workspaceRef.current = Blockly.inject(wrapperRef.current, { + // Unfortuntely Blockly mutates the toolbox object when initialising. This + // means that the dep changes between renders, which in turn means that + // the workspace is re-injected + toolbox: _.cloneDeep(toolbox), + theme: Theme, + // does this mean it cannot be https://developers.google.com/blockly/guides/configure/web/resizable ? + }); + + // Set the initial block in the workspace + if (initialBlock) { + let block = workspaceRef.current.newBlock(initialBlock.kind); + block.moveBy(initialBlock.x, initialBlock.y); + block.initSvg(); + + workspaceRef.current.render(); + } + + return () => { + workspaceRef.current.dispose(); + }; + }, [toolbox, initialBlock, theme]); + + return useMemo( + () => ({ + // Return a component to inject the workspace + BlocklyComponent: () => ( +
+ ), + // Generate code from the workspace + generate: () => { + return BlocklyJS.workspaceToCode(workspaceRef.current); + }, + }), + [] + ); +} diff --git a/client/src/Blocks/cyf.js b/client/src/Blocks/cyf.js new file mode 100644 index 0000000..e38dab7 --- /dev/null +++ b/client/src/Blocks/cyf.js @@ -0,0 +1,145 @@ +import * as Blockly from "blockly/core"; +import BlocklyJavaScript from "blockly/javascript"; + +function provideRandomInt() { + return BlocklyJavaScript.provideFunction_("randomInt", [ + "function " + BlocklyJavaScript.FUNCTION_NAME_PLACEHOLDER_ + "(n) {", + " // Return a random number from in [0, n[", + " return Math.floor(Math.random()*n);", + "}", + ]); +} + +function provideRandomMember() { + let randomInt = provideRandomInt(); + return BlocklyJavaScript.provideFunction_("randomMember", [ + "function " + BlocklyJavaScript.FUNCTION_NAME_PLACEHOLDER_ + "(arr) {", + " // Return a random member of the array", + " return arr[" + randomInt + "(arr.length)]", + "}", + ]); +} + +Blockly.defineBlocksWithJsonArray([ + { + type: "get_randomWord", + message0: "get a random %1", + args0: [ + { + type: "field_dropdown", + name: "TYPE", + options: [ + ["word", "WORD"], + ["noun", "NOUN"], + ["verb", "VERB"], + ["adjective", "ADJECTIVE"], + ], + }, + ], + output: "String", + colour: "%{BKY_TEXTS_HUE}", + }, + { + type: "get_randomMember", + message0: "get a random item from array %1", + args0: [ + { + type: "input_value", + name: "ARRAY", + }, + ], + output: null, + style: "list_blocks", + }, + { + type: "text_to_speech", + message0: "say %1", + args0: [ + { + type: "input_value", + name: "VALUE", + }, + ], + previousStatement: null, + nextStatement: null, + colour: "%{BKY_TEXTS_HUE}", + }, + { + type: "get_sum", + message0: "get the sum of the numbers in array %1", + args0: [ + { + type: "input_value", + name: "ARRAY", + }, + ], + output: Number, + style: "list_blocks", + }, +]); + +BlocklyJavaScript["get_randomWord"] = function (block) { + const getWords = BlocklyJavaScript.provideFunction_("getWords", [ + "function " + BlocklyJavaScript.FUNCTION_NAME_PLACEHOLDER_ + "(type) {", + " // Return words of a given type, or all words if type is 'WORD'", + " let words = [", + " {type: 'ADJECTIVE', value: 'big'},", + " {type: 'ADJECTIVE', value: 'purple'},", + " {type: 'ADJECTIVE', value: 'miscellaneous'},", + " {type: 'ADJECTIVE', value: 'interesting'},", + " {type: 'ADJECTIVE', value: 'collapsed'},", + " {type: 'NOUN', value: 'umbrella'},", + " {type: 'NOUN', value: 'knee'},", + " {type: 'NOUN', value: 'banana'},", + " {type: 'NOUN', value: 'platypus'},", + " {type: 'NOUN', value: 'bottle'},", + " {type: 'VERB', value: 'delineated'},", + " {type: 'VERB', value: 'read'},", + " {type: 'VERB', value: 'saw'},", + " {type: 'VERB', value: 'ate'},", + " {type: 'VERB', value: 'magicked'},", + " ];", + " return words.filter(word => type === 'WORD' || word.type === type).map(word => word.value);", + "}", + ]); + const type = block.getFieldValue("TYPE"); + const randomMember = provideRandomMember(); + return [ + `${randomMember}(${getWords}('${type}'))`, + BlocklyJavaScript.ORDER_FUNCTION_CALL, + ]; +}; + +BlocklyJavaScript["get_randomMember"] = function (block) { + const randomMember = provideRandomMember(); + var array = + BlocklyJavaScript.valueToCode( + block, + "ARRAY", + BlocklyJavaScript.ORDER_NONE + ) || "[]"; + return [`${randomMember}(${array})`, BlocklyJavaScript.ORDER_FUNCTION_CALL]; +}; + +BlocklyJavaScript["get_sum"] = function (block) { + var array = + BlocklyJavaScript.valueToCode( + block, + "ARRAY", + BlocklyJavaScript.ORDER_MEMBER + ) || "[]"; + return [ + `${array}.reduce((a,b) => a+b, 0)`, + BlocklyJavaScript.ORDER_FUNCTION_CALL, + ]; +}; + +BlocklyJavaScript["text_to_speech"] = function (block) { + var utterance = + BlocklyJavaScript.valueToCode( + block, + "VALUE", + BlocklyJavaScript.ORDER_NONE + ) || ""; + return `window.speechSynthesis.speak(new SpeechSynthesisUtterance(${utterance}));\n`; +}; diff --git a/client/src/Blocks/dom.js b/client/src/Blocks/dom.js new file mode 100644 index 0000000..8316254 --- /dev/null +++ b/client/src/Blocks/dom.js @@ -0,0 +1,691 @@ +import * as Blockly from "blockly/core"; +import BlocklyJavaScript from "blockly/javascript"; + +// customised messages +Blockly.Msg["CONTROLS_FOREACH_TITLE"] = "for each item %1 in array %2"; + +Blockly.Msg["LISTS_CREATE_EMPTY_TITLE"] = "create empty array"; +Blockly.Msg["LISTS_CREATE_EMPTY_TOOLTIP"] = + "Returns an array, of length 0, containing no data records"; +Blockly.Msg["LISTS_CREATE_WITH_CONTAINER_TITLE_ADD"] = "array"; +Blockly.Msg["LISTS_CREATE_WITH_CONTAINER_TOOLTIP"] = + "Add, remove, or reorder sections to reconfigure this array block."; +Blockly.Msg["LISTS_CREATE_WITH_INPUT_WITH"] = "create array with"; +Blockly.Msg["LISTS_CREATE_WITH_ITEM_TOOLTIP"] = "Add an item to the array."; +Blockly.Msg["LISTS_CREATE_WITH_TOOLTIP"] = + "Create an array with any number of items."; + +// copied from Blockly.Generator.prototype.statementToCode (but doesn't add indentation) +BlocklyJavaScript.statementToCodeNoIndent = function (block, name) { + var targetBlock = block.getInputTargetBlock(name); + var code = this.blockToCode(targetBlock); + // Value blocks must return code and order of operations info. + // Statement blocks must only return code. + if (typeof code !== "string") { + throw TypeError( + "Expecting code from statement block: " + + (targetBlock && targetBlock.type) + ); + } + return code; +}; + +BlocklyJavaScript.getWithContextVariable = function () { + if (!(this.contextVariableStack && this.contextVariableStack[0])) { + return ""; + } + return this.contextVariableStack[0]; +}; + +BlocklyJavaScript.pushWithContextVariable = function (variableName) { + this.contextVariableStack = this.contextVariableStack || []; + this.contextVariableStack.unshift(variableName); +}; + +BlocklyJavaScript.popWithContextVariable = function () { + if (!(this.contextVariableStack && this.contextVariableStack[0])) { + return ""; + } + return this.contextVariableStack.shift(); +}; + +Blockly.Blocks["arrays_getFirst"] = { + /** + * Block for getting element at index. + * @this {Blockly.Block} + */ + init: function () { + var MODE = [ + [Blockly.Msg["LISTS_GET_INDEX_GET"], "GET"], + [Blockly.Msg["LISTS_GET_INDEX_GET_REMOVE"], "GET_REMOVE"], + [Blockly.Msg["LISTS_GET_INDEX_REMOVE"], "REMOVE"], + ]; + var WHERE_OPTIONS = [ + [Blockly.Msg["LISTS_GET_INDEX_FIRST"], "FIRST"], + [Blockly.Msg["LISTS_GET_INDEX_LAST"], "LAST"], + ]; + this.setStyle("list_blocks"); + + var modeMenu = new Blockly.FieldDropdown(MODE, function (value) { + var isStatement = value === "REMOVE"; + this.getSourceBlock().updateStatement_(isStatement); + }); + this.appendDummyInput() + .appendField("", "SPACE") + .appendField(modeMenu, "MODE"); + + var atMenu = new Blockly.FieldDropdown(WHERE_OPTIONS); + this.appendDummyInput().appendField("the").appendField(atMenu, "WHERE"); + this.appendValueInput("VALUE") + .setCheck("Array") + .appendField("item from the array"); + + this.setInputsInline(true); + this.setOutput(true); + }, + /* + * mutationToDom and domToMutation are only here for backward compatibilty with xml (probably never needed) + */ + /** + * Create XML to represent whether the block is a statement or a value. + * Also represent whether there is an 'AT' input. + * @return {!Element} XML storage element. + * @this {Blockly.Block} + */ + mutationToDom: function () { + var container = Blockly.utils.xml.createElement("mutation"); + var isStatement = !this.outputConnection; + container.setAttribute("statement", isStatement); + return container; + }, + /** + * Parse XML to restore the 'AT' input. + * @param {!Element} xmlElement XML storage element. + * @this {Blockly.Block} + */ + domToMutation: function (xmlElement) { + // Note: Until January 2013 this block did not have mutations, + // so 'statement' defaults to false and 'at' defaults to true. + var isStatement = xmlElement.getAttribute("statement") === "true"; + this.updateStatement_(isStatement); + }, + /** + * Switch between a value block and a statement block. + * @param {boolean} newStatement True if the block should be a statement. + * False if the block should be a value. + * @private + * @this {Blockly.Block} + */ + updateStatement_: function (newStatement) { + var oldStatement = !this.outputConnection; + if (newStatement !== oldStatement) { + this.unplug(true, true); + if (newStatement) { + this.setOutput(false); + this.setPreviousStatement(true); + this.setNextStatement(true); + } else { + this.setPreviousStatement(false); + this.setNextStatement(false); + this.setOutput(true); + } + } + }, +}; + +const WITH_CONTEXTS = [ + "add_element", + "with_element_by_id", + "with_element_by_selector", + "with_elements_by_selector", + "element_clicked", +]; + +function validateInWithContext(_e) { + if (!this.workspace.isDragging || this.workspace.isDragging()) { + return; // Don't change state at the start of a drag. + } + var legal = false; + // Is the block nested in a procedure? + var block = this.getSurroundParent(); + while (block) { + if (WITH_CONTEXTS.indexOf(block.type) !== -1) { + legal = true; + break; + } + block = block.getSurroundParent(); + } + if (legal) { + this.setWarningText(null); + if (!this.isInFlyout) { + this.setEnabled(true); + } + } else { + this.setWarningText( + "This block can only be used inside the 'create a new ... element' and 'find the element with id' blocks" + ); + if (!this.isInFlyout && !this.getInheritedDisabled()) { + this.setEnabled(false); + } + } +} + +Blockly.Extensions.register("validate_in_with_context", function () { + this.setOnChange(validateInWithContext); +}); + +Blockly.defineBlocksWithJsonArray([ + { + type: "element_clicked", + message0: "%1 %2 %3 %4 %5", + args0: [ + { + type: "field_label_serializable", + name: "TEXT1", + text: "when the element with id", + }, + { + type: "field_input", + name: "ID", + text: "button", + }, + { + type: "field_label_serializable", + name: "TEXT2", + text: "is clicked", + }, + { + type: "input_dummy", + }, + { + type: "input_statement", + name: "HANDLER", + }, + ], + colour: "%{BKY_COLOUR_HUE}", + tooltip: "When a button is clicked", + helpUrl: "", + }, + { + type: "element_clicked_current", + message0: "%1 %2 %3", + args0: [ + { + type: "field_label_serializable", + name: "TEXT1", + text: "when the element is clicked", + }, + { + type: "input_dummy", + }, + { + type: "input_statement", + name: "HANDLER", + }, + ], + previousStatement: null, + nextStatement: null, + colour: 60, + tooltip: "When a button is clicked", + helpUrl: "", + extensions: ["validate_in_with_context"], + }, + { + type: "on_start", + message0: "%1 %2 %3", + args0: [ + { + type: "field_label_serializable", + name: "TEXT1", + text: "at the start (when run is clicked)", + }, + { + type: "input_dummy", + }, + { + type: "input_statement", + name: "HANDLER", + }, + ], + inputsInline: false, + colour: "%{BKY_COLOUR_HUE}", + tooltip: "At the start", + helpUrl: "", + }, + { + type: "set_attribute", + message0: "set the attribute %1 to %2", + args0: [ + { + type: "field_dropdown", + name: "PROPERTY", + options: [ + ["src", "src"], + ["href", "href"], + ["background", "backgroundColor"], + ["color", "color"], + ["id", "id"], + ["class", "class"], + ["is visible", "visibility"], + ], + }, + { + type: "input_value", + name: "VALUE", + }, + ], + previousStatement: null, + nextStatement: null, + colour: 60, + extensions: ["validate_in_with_context"], + }, + { + type: "set_content", + message0: "set the text content to %1", + args0: [ + { + type: "input_value", + name: "VALUE", + }, + ], + previousStatement: null, + nextStatement: null, + colour: 60, + extensions: ["validate_in_with_context"], + }, + { + type: "get_input_value_with_id", + message0: "get the value of the with id %1", + args0: [ + { + type: "field_input", + name: "ID", + text: "text", + }, + ], + output: "String", + colour: 60, + }, + { + type: "remove_contents", + message0: "remove the contents of the element", + args0: [], + previousStatement: null, + nextStatement: null, + colour: 60, + extensions: ["validate_in_with_context"], + }, + { + type: "arrays_push", + message0: "add %1 at the %2 of array %3", + args0: [ + { + type: "input_value", + name: "VALUE", + }, + { + type: "field_dropdown", + name: "WHERE", + options: [ + ["start", "START"], + ["end", "END"], + ], + }, + { + type: "input_value", + name: "LIST", + check: "Array", + }, + ], + previousStatement: null, + nextStatement: null, + style: "list_blocks", + }, + { + type: "arrays_forEach", + message0: "%{BKY_CONTROLS_FOREACH_TITLE}", + args0: [ + { + type: "field_variable", + name: "VAR", + variable: null, + }, + { + type: "input_value", + name: "LIST", + check: "Array", + }, + ], + message1: "%{BKY_CONTROLS_REPEAT_INPUT_DO} %1", + args1: [ + { + type: "input_statement", + name: "DO", + }, + ], + previousStatement: null, + nextStatement: null, + style: "loop_blocks", + extensions: ["contextMenu_newGetVariableBlock", "controls_forEach_tooltip"], + }, +]); + +BlocklyJavaScript["arrays_getFirst"] = function (block) { + var mode = block.getFieldValue("MODE"); + var where = block.getFieldValue("WHERE"); + var listOrder = BlocklyJavaScript.ORDER_MEMBER; + var list = BlocklyJavaScript.valueToCode(block, "VALUE", listOrder) || "[]"; + if (mode === "GET") { + let op = where === "FIRST" ? "[0]" : ".slice(-1)[0]"; + let code = list + op; + return [code, BlocklyJavaScript.ORDER_MEMBER]; + } else if (mode === "GET_REMOVE") { + let op = where === "FIRST" ? ".shift()" : ".pop()"; + let code = list + op; + return [code, BlocklyJavaScript.ORDER_MEMBER]; + } else if (mode === "REMOVE") { + let op = where === "FIRST" ? ".shift()" : ".pop()"; + return list + op + ";\n"; + } +}; + +BlocklyJavaScript["arrays_push"] = function (block) { + var where = block.getFieldValue("WHERE"); + var list = + BlocklyJavaScript.valueToCode( + block, + "LIST", + BlocklyJavaScript.ORDER_MEMBER + ) || "[]"; + var value = + BlocklyJavaScript.valueToCode( + block, + "VALUE", + BlocklyJavaScript.ORDER_NONE // going to be passed as a single argument to unshift + ) || "null"; + var op = where === "START" ? ".unshift" : ".push"; + return list + op + "(" + value + ");\n"; +}; + +BlocklyJavaScript["arrays_forEach"] = function (block) { + var variable = BlocklyJavaScript.nameDB_.getName( + block.getFieldValue("VAR"), + Blockly.VARIABLE_CATEGORY_NAME + ); + var list = + BlocklyJavaScript.valueToCode( + block, + "LIST", + BlocklyJavaScript.ORDER_MEMBER + ) || "[]"; + var branch = BlocklyJavaScript.statementToCode(block, "DO"); + // don't need a loop trap + var code = ""; + code += list + ".forEach((" + variable + ") => {\n" + branch + "});\n"; + return code; +}; + +BlocklyJavaScript["element_clicked"] = function (block) { + var text_id = block.getFieldValue("ID"); + + //let branch = BlocklyJavaScript.statementToCodeNoIndent(block, "STACK"); + BlocklyJavaScript.pushWithContextVariable("event.target"); + var statements_handler = BlocklyJavaScript.statementToCode(block, "HANDLER"); + BlocklyJavaScript.popWithContextVariable(); + // TODO: Assemble JavaScript into code variable. + var code = ` +document.getElementById('${text_id}').addEventListener('click', (event) => { +${statements_handler} +});`; + return code; +}; + +/* Find the variables referenced inside the block. Modify the nameDB/variableMap so that + * they think they are called name_. + * returns an array of object. + * The globalName is the name of the global variable + * The localName is the new local name that will be used until restoreGlobalVariables is called. + */ +function renameLocalVariablesUsedInBlock(block) { + let usedVariablesById = {}; + block + .getDescendants() + .filter((b) => b.type === "variables_get") + .forEach((b) => { + usedVariablesById[b.getFieldValue("VAR")] = { + id: b.getFieldValue("VAR"), + }; + }); + let renamedVariables = Object.values(usedVariablesById); + renamedVariables.forEach((variable) => { + variable.blocklyVariable = + BlocklyJavaScript.nameDB_.variableMap_.getVariableById(variable.id); + variable.globalName = variable.blocklyVariable.name; + variable.blocklyVariable.name = variable.globalName + "_local"; + // ensure no collisions + variable.localName = BlocklyJavaScript.nameDB_.getName( + variable.id, + Blockly.Variables.NAME_TYPE + ); + }); + return renamedVariables; +} + +function restoreGlobalVariables(localVariables) { + localVariables.forEach((variable) => { + variable.blocklyVariable.name = variable.globalName; + }); +} + +BlocklyJavaScript["element_clicked_current"] = function (block) { + let localVariables = renameLocalVariablesUsedInBlock(block); + var statements_handler = BlocklyJavaScript.statementToCode(block, "HANDLER"); + restoreGlobalVariables(localVariables); + + var code = "\n"; + localVariables.forEach((variable) => { + code += `// deep copy global variable '${variable.globalName}' so the current +// value is available inside the event listener +let ${variable.localName} = JSON.parse(JSON.stringify(${variable.globalName})); +`; + }); + let withContextVariable = BlocklyJavaScript.getWithContextVariable(); + code += `${withContextVariable}.addEventListener('click', (event) => { +${statements_handler} +});`; + return code; +}; + +BlocklyJavaScript["on_start"] = function (block) { + var code = BlocklyJavaScript.statementToCodeNoIndent(block, "HANDLER"); + return code; +}; + +Blockly.Blocks["with_element_by_id"] = { + init: function () { + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setColour(45); + this.appendDummyInput() + .appendField("find the element with id") + .appendField(new Blockly.FieldTextInput("list"), "ID"); + this.appendStatementInput("STACK").appendField("and"); + }, +}; + +BlocklyJavaScript["with_element_by_id"] = function (block) { + let elementId = block.getFieldValue("ID"); + let elementVar = BlocklyJavaScript.nameDB_.getDistinctName( + "element_" + elementId, + Blockly.Variables.NAME_TYPE + ); + BlocklyJavaScript.pushWithContextVariable(elementVar); + let branch = BlocklyJavaScript.statementToCodeNoIndent(block, "STACK"); + BlocklyJavaScript.popWithContextVariable(); + return `let ${elementVar} = document.getElementById(${BlocklyJavaScript.quote_( + elementId + )}); +${branch}`; +}; + +Blockly.Blocks["with_element_by_selector"] = { + init: function () { + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setColour(45); + this.appendDummyInput() + .appendField("find the element using css selector") + .appendField(new Blockly.FieldTextInput("#list"), "QUERY"); + this.appendStatementInput("STACK").appendField("and"); + }, +}; + +BlocklyJavaScript["with_element_by_selector"] = function (block) { + let elementQuery = block.getFieldValue("QUERY"); + let elementVar = BlocklyJavaScript.nameDB_.getDistinctName( + "selectedElement", + Blockly.Variables.NAME_TYPE + ); + BlocklyJavaScript.pushWithContextVariable(elementVar); + let branch = BlocklyJavaScript.statementToCodeNoIndent(block, "STACK"); + BlocklyJavaScript.popWithContextVariable(); + return `let ${elementVar} = document.querySelector(${BlocklyJavaScript.quote_( + elementQuery + )}); +${branch}`; +}; + +Blockly.Blocks["with_elements_by_selector"] = { + init: function () { + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setColour(45); + this.appendDummyInput() + .appendField("find all the elements using css selector") + .appendField(new Blockly.FieldTextInput("#list"), "QUERY"); + this.appendStatementInput("STACK").appendField("and with each"); + }, +}; + +BlocklyJavaScript["with_elements_by_selector"] = function (block) { + let elementQuery = block.getFieldValue("QUERY"); + let elementVar = BlocklyJavaScript.nameDB_.getDistinctName( + "selectedElement", + Blockly.Variables.NAME_TYPE + ); + BlocklyJavaScript.pushWithContextVariable(elementVar); + let branch = BlocklyJavaScript.statementToCode(block, "STACK"); + BlocklyJavaScript.popWithContextVariable(); + return `document.querySelectorAll(${BlocklyJavaScript.quote_( + elementQuery + )}).forEach((${elementVar}) => { +${branch} +}); +`; +}; + +BlocklyJavaScript["set_attribute"] = function (block) { + let value = BlocklyJavaScript.valueToCode( + block, + "VALUE", + BlocklyJavaScript.ORDER_ASSIGNMENT + ); + let property = block.getFieldValue("PROPERTY"); + if (property === "visibility") { + value = `(${value}) ? 'visible' : 'hidden'`; + } + let styleAttributes = ["color", "backgroundColor", "visibility"]; + let withContextVariable = BlocklyJavaScript.getWithContextVariable(); + if (styleAttributes.includes(property)) { + return withContextVariable + ".style." + property + " = " + value + ";\n"; + } else { + return ( + withContextVariable + + '.setAttribute("' + + property + + '", ' + + value + + ");\n" + ); + } +}; + +BlocklyJavaScript["set_content"] = function (block) { + let value = BlocklyJavaScript.valueToCode( + block, + "VALUE", + BlocklyJavaScript.ORDER_ASSIGNMENT + ); + let withContextVariable = BlocklyJavaScript.getWithContextVariable(); + return withContextVariable + ".innerText = " + value + ";\n"; +}; + +BlocklyJavaScript["get_input_value_with_id"] = function (block) { + let elementId = block.getFieldValue("ID"); + BlocklyJavaScript.provideFunction_("getNumberOrString", [ + "function " + BlocklyJavaScript.FUNCTION_NAME_PLACEHOLDER_ + "(value) {", + " // Convert a string value to a number if possible", + " let number_value = Number(value);", + " if (Number.isNaN(number_value)) {", + " return value", + " } else {", + " return number_value", + " }", + "}", + ]); + return [ + `getNumberOrString(document.getElementById(${BlocklyJavaScript.quote_( + elementId + )}).value)`, + BlocklyJavaScript.ORDER_MEMBER, + ]; +}; + +BlocklyJavaScript["remove_contents"] = function (block) { + let withContextVariable = BlocklyJavaScript.getWithContextVariable(); + return withContextVariable + ".replaceChildren();\n"; +}; + +Blockly.Blocks["add_element"] = { + init: function () { + this.setPreviousStatement(true); + this.setNextStatement(true); + this.setColour(60); + this.appendDummyInput() + .appendField("create a new") + .appendField( + new Blockly.FieldDropdown([ + ["
  • ", "li"], + ["
      ", "ul"], + ["
        ", "ol"], + [" + )} + + ); +}; +export default Button; diff --git a/client/src/Button/Button.scss b/client/src/Button/Button.scss new file mode 100644 index 0000000..640a26d --- /dev/null +++ b/client/src/Button/Button.scss @@ -0,0 +1,50 @@ +@import "../theme/utilities.scss"; + +.c-button { + // reset + appearance: none; + font: 100% var(--theme-font--display); + text-transform: uppercase; + text-decoration: none; + display: inline-flex; + place-items: center; + min-width: fit-content; + height: fit-content; + gap: var(--theme-spacing--2); + + // reset spacing to text instead of viewport + --theme-spacing--1: 0.175em; + --theme-spacing--2: 0.375em; + --theme-spacing--3: 0.5em; + --theme-spacing--4: 0.75em; + // styles + color: var(--theme-color--ink); + background-color: var(--theme-color--paper); + border: var(--theme-border--thick); + border-radius: var(--theme-border-radius); + padding: var(--theme-spacing--3) var(--theme-spacing--4); + box-shadow: var(--theme-spacing--1) var(--theme-spacing--1) + var(--theme-color--pop); + transition: background 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); + + @include on-event { + @include dots($color: var(--theme-color--pop)); + } + + //elements + + &__text { + white-space: nowrap; + } + + &__icon { + width: 1em; + height: 1em; + border: 0; + box-shadow: 0; + } + + &--handle { + height: 100%; + } +} diff --git a/client/src/Exercises/01-stuff/index.js b/client/src/Exercises/01-stuff/index.js new file mode 100644 index 0000000..155e81e --- /dev/null +++ b/client/src/Exercises/01-stuff/index.js @@ -0,0 +1,46 @@ +import LessonMarkdown from "../../LessonMarkdown"; +import markdownUrl from "./lesson.md"; + +export function Lesson() { + return ; +} + +export const toolbox = { + kind: "categoryToolbox", + contents: [ + { + kind: "category", + name: "Values", + contents: [ + { + kind: "block", + type: "text", + }, + { + kind: "block", + type: "get_randomWord", + }, + ], + }, + { + kind: "category", + name: "HTML", + contents: [ + { + kind: "block", + type: "on_start", + }, + { + kind: "block", + type: "with_element_by_id", + }, + { + kind: "block", + type: "set_content", + blockxml: + " ", + }, + ], + }, + ], +}; diff --git a/client/src/Exercises/01-stuff/lesson.md b/client/src/Exercises/01-stuff/lesson.md new file mode 100644 index 0000000..fd1b8ee --- /dev/null +++ b/client/src/Exercises/01-stuff/lesson.md @@ -0,0 +1,5 @@ +## Some stuff + +This lesson is about some stuff™️. + +I'm writing some things here, blah, blah, blah. diff --git a/client/src/Exercises/02-more-stuff/index.js b/client/src/Exercises/02-more-stuff/index.js new file mode 100644 index 0000000..111e3c6 --- /dev/null +++ b/client/src/Exercises/02-more-stuff/index.js @@ -0,0 +1,58 @@ +import LessonMarkdown from "../../LessonMarkdown"; +import markdownUrl from "./lesson.md"; + +export function Lesson() { + return ; +} + +export const toolbox = { + kind: "categoryToolbox", + contents: [ + { + kind: "category", + name: "Values", + contents: [ + { + kind: "block", + type: "math_number", + }, + { + kind: "block", + type: "text", + }, + { + kind: "block", + type: "colour_picker", + }, + { + kind: "block", + type: "logic_boolean", + }, + ], + }, + { + kind: "category", + name: "HTML", + contents: [ + { + kind: "block", + type: "on_start", + }, + { + kind: "block", + type: "with_element_by_id", + }, + { + kind: "block", + type: "set_content", + blockxml: + " ", + }, + { + kind: "block", + type: "set_attribute", + }, + ], + }, + ], +}; diff --git a/client/src/Exercises/02-more-stuff/lesson.md b/client/src/Exercises/02-more-stuff/lesson.md new file mode 100644 index 0000000..b8863ec --- /dev/null +++ b/client/src/Exercises/02-more-stuff/lesson.md @@ -0,0 +1,5 @@ +## Some more stuff + +This lesson is about some more stuff™️. + +I'm writing some more things here, blah, blah, blah. diff --git a/client/src/Layout/Footer/Footer.jsx b/client/src/Layout/Footer/Footer.jsx new file mode 100644 index 0000000..d361142 --- /dev/null +++ b/client/src/Layout/Footer/Footer.jsx @@ -0,0 +1,12 @@ +import "./Footer.scss"; +import { ReactComponent as Logo } from "../../svgs/Logo.svg"; + +const Footer = () => ( +
        +

        + + +

        +
        +); +export default Footer; diff --git a/client/src/Layout/Footer/Footer.scss b/client/src/Layout/Footer/Footer.scss new file mode 100644 index 0000000..9479ec6 --- /dev/null +++ b/client/src/Layout/Footer/Footer.scss @@ -0,0 +1,11 @@ +@import "../../theme/utilities.scss"; + +.c-footer { + @include grid-assign(title); + grid-template: + ". ..... ..... . " var(--theme-spacing--3) + ". title title ." auto + ". ..... ..... ." var(--theme-spacing--3) / var(--theme-spacing--2) 1fr auto var( + --theme-spacing--2 + ); +} diff --git a/client/src/Layout/Header/Header.jsx b/client/src/Layout/Header/Header.jsx new file mode 100644 index 0000000..fdbe442 --- /dev/null +++ b/client/src/Layout/Header/Header.jsx @@ -0,0 +1,17 @@ +import React, { useState } from "react"; +import Button from "../../Button/Button"; +import "./Header.scss"; + +const Header = () => { + const [open, setOpen] = useState(false); + + const toggleMenu = () => setOpen(!open); + + return ( +
        +

        +
        + ); +}; +export default Header; diff --git a/client/src/Layout/Header/Header.scss b/client/src/Layout/Header/Header.scss new file mode 100644 index 0000000..b2f4263 --- /dev/null +++ b/client/src/Layout/Header/Header.scss @@ -0,0 +1,17 @@ +@import "../../theme/utilities.scss"; + +.c-header { + @include grid-assign(title, text, output, menu); + gap: var(--theme-spacing--2); + grid-template: + ". ..... .... ...... .... . " 0 + ". title text output menu ." auto + ". ..... .... ...... .... ." 0 / 0 1fr auto auto auto 0; + + align-items: center; + + &__title { + text-transform: lowercase; + letter-spacing: 4px; + } +} diff --git a/client/src/Layout/Menu/Menu.jsx b/client/src/Layout/Menu/Menu.jsx new file mode 100644 index 0000000..8efb259 --- /dev/null +++ b/client/src/Layout/Menu/Menu.jsx @@ -0,0 +1,104 @@ +import React, { useState } from "react"; +import "./Menu.scss"; + +// I've put dummy text in assuming you have planned to extract these links and titles from the ExerciseIndex? +const Menu = () => { + const [open, setOpen] = useState(false); + + const toggleMenu = () => setOpen(!open); + return ( + + ); +}; + +export default Menu; diff --git a/client/src/Layout/Menu/Menu.scss b/client/src/Layout/Menu/Menu.scss new file mode 100644 index 0000000..c489d8c --- /dev/null +++ b/client/src/Layout/Menu/Menu.scss @@ -0,0 +1,28 @@ +@import "../../theme/utilities.scss"; + +.c-menu { + @include invert; + // slide on + position: absolute; + top: 0; + transform: translateX(100vw); + width: 100vw; + height: 100vh; + z-index: 3; + transition: transform 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); + + &.is-open { + transform: translateX(0); + } + + display: grid; + place-content: center; + + &__wrapper { + max-width: 1180px; // !mn update to container + } + + &__list { + list-style: none; + } +} diff --git a/client/src/LessonMarkdown.js b/client/src/LessonMarkdown.js new file mode 100644 index 0000000..9e9d67c --- /dev/null +++ b/client/src/LessonMarkdown.js @@ -0,0 +1,32 @@ +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; + +export default function LessonMarkdown({ url }) { + const [status, setStatus] = useState("pending"); + const [markdown, setMarkdown] = useState(""); + + useEffect(() => { + (async function getText() { + try { + const res = await fetch(url); + const text = await res.text(); + setMarkdown(text); + setStatus("done"); + } catch (e) { + setStatus("error"); + } + })(); + }, [url]); + + if (status === "pending") { + return Loading…; + } else if (status === "error") { + return ( + + Something went wrong + + ); + } else { + return {markdown}; + } +} diff --git a/client/src/Output/Output.jsx b/client/src/Output/Output.jsx new file mode 100644 index 0000000..2bbcf12 --- /dev/null +++ b/client/src/Output/Output.jsx @@ -0,0 +1,36 @@ +import React from "react"; +import Button from "../Button/Button"; +import okaida from "prism-react-renderer/themes/okaidia"; +import "./Output.scss"; +import Highlight, { defaultProps } from "prism-react-renderer"; + +const Output = ({ generatedCode, generateCodeButton }) => ( +
        +
        +); + +export default Output; diff --git a/client/src/Output/Output.scss b/client/src/Output/Output.scss new file mode 100644 index 0000000..0da1039 --- /dev/null +++ b/client/src/Output/Output.scss @@ -0,0 +1,17 @@ +@import "../theme/utilities.scss"; + +.c-output { + max-width: 80ch; + overflow: hidden; + pre { + margin: 0; + padding: var(--theme-spacing--2); + padding-bottom: 25ch; + width: 100%; + min-height: 50vh; + overflow: auto; + border: var(--theme-border--thick); + border-radius: var(--theme-border-radius--subtle); + border-color: var(--theme-color--pop); + } +} diff --git a/client/src/TextPanel/TextPanel.js b/client/src/TextPanel/TextPanel.js new file mode 100644 index 0000000..c9b63b7 --- /dev/null +++ b/client/src/TextPanel/TextPanel.js @@ -0,0 +1,29 @@ +import React from "react"; +import Button from "../Button/Button"; +import "./TextPanel.scss"; + +const TextPanel = ({ exercise, navigation }) => ( +
        + +
        + +
        +
        +); + +export default TextPanel; diff --git a/client/src/TextPanel/TextPanel.scss b/client/src/TextPanel/TextPanel.scss new file mode 100644 index 0000000..85b22cd --- /dev/null +++ b/client/src/TextPanel/TextPanel.scss @@ -0,0 +1,16 @@ +@import "../theme/utilities.scss"; + +.c-textpanel { + @include grid-assign(nav, text); + grid-template: + "nav" auto + "text" 1fr / 1fr; + gap: var(--theme-spacing--2); + + &__text { + background: var(--theme-color--paper); + padding: var(--theme-spacing--2) var(--theme-spacing--3); + max-width: 55ch; + line-height: 1.5; + } +} diff --git a/client/src/Toast/Toast.jsx b/client/src/Toast/Toast.jsx new file mode 100644 index 0000000..e69de29 diff --git a/client/src/Toast/Toast.scss b/client/src/Toast/Toast.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/src/Tools/Tools.jsx b/client/src/Tools/Tools.jsx new file mode 100644 index 0000000..4d727de --- /dev/null +++ b/client/src/Tools/Tools.jsx @@ -0,0 +1,6 @@ +//tools we need + +// generate code +// copy code +// share slightly mad XML link +// pull out handles diff --git a/client/src/Tools/Tools.scss b/client/src/Tools/Tools.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/src/index.js b/client/src/index.js new file mode 100644 index 0000000..1847286 --- /dev/null +++ b/client/src/index.js @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import App from "./App"; + +import "./index.scss"; + +ReactDOM.render( + + + , + document.getElementById("root") +); diff --git a/client/src/index.scss b/client/src/index.scss new file mode 100644 index 0000000..200988c --- /dev/null +++ b/client/src/index.scss @@ -0,0 +1,11 @@ +@import "theme/global.scss"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, + Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; +} diff --git a/client/src/svgs/Humaaans - Wireframe.svg b/client/src/svgs/Humaaans - Wireframe.svg new file mode 100644 index 0000000..e8b4282 --- /dev/null +++ b/client/src/svgs/Humaaans - Wireframe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Interfaces-2.svg b/client/src/svgs/Humaaans-Interfaces-2.svg new file mode 100644 index 0000000..c52e136 --- /dev/null +++ b/client/src/svgs/Humaaans-Interfaces-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Interfaces.svg b/client/src/svgs/Humaaans-Interfaces.svg new file mode 100644 index 0000000..62b789e --- /dev/null +++ b/client/src/svgs/Humaaans-Interfaces.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Phone.svg b/client/src/svgs/Humaaans-Phone.svg new file mode 100644 index 0000000..1d2b649 --- /dev/null +++ b/client/src/svgs/Humaaans-Phone.svg @@ -0,0 +1,20 @@ + + + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Space.svg b/client/src/svgs/Humaaans-Space.svg new file mode 100644 index 0000000..80b2725 --- /dev/null +++ b/client/src/svgs/Humaaans-Space.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Wireframes-1.svg b/client/src/svgs/Humaaans-Wireframes-1.svg new file mode 100644 index 0000000..aa06c12 --- /dev/null +++ b/client/src/svgs/Humaaans-Wireframes-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Wireframes-2.svg b/client/src/svgs/Humaaans-Wireframes-2.svg new file mode 100644 index 0000000..daba55b --- /dev/null +++ b/client/src/svgs/Humaaans-Wireframes-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Humaaans-Wireframes-3.svg b/client/src/svgs/Humaaans-Wireframes-3.svg new file mode 100644 index 0000000..988f9b3 --- /dev/null +++ b/client/src/svgs/Humaaans-Wireframes-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/svgs/Logo.svg b/client/src/svgs/Logo.svg new file mode 100644 index 0000000..02e7a65 --- /dev/null +++ b/client/src/svgs/Logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/src/theme/base.scss b/client/src/theme/base.scss new file mode 100644 index 0000000..b1265c3 --- /dev/null +++ b/client/src/theme/base.scss @@ -0,0 +1,44 @@ +@import-normalize; +* { + box-sizing: border-box; +} +body, +html { + font-family: var(--theme-font--copy); + font-size: 100%; + // background-color: var(--theme-color--paper); + color: var(--theme-color--ink); + @include dots($color: var(--theme-color--ink-fade), $spacing: 25px); + margin: 0; +} + +$heading__levels: 1, 2, 3, 4, 5, 6; +@each $h in $heading__levels { + h#{$h} { + font-family: var(--theme-font--display); + font-size: var(--theme-type-size--#{$h}); + margin: 0; + } +} + +a, +a:link, +a:visited { + color: currentColor; + text-decoration: none; + background-image: linear-gradient( + 120deg, + var(--theme-color--accent) 0%, + var(--theme-color--pop) 100% + ); + background-repeat: no-repeat; + background-size: 100% 0.2em; + background-position: 0 88%; + transition: background-size 0.25s cubic-bezier(0.68, -0.55, 0.265, 1.55), + color 0.3s; + + @include on-event { + color: var(--theme-color--paper); + background-size: 100% 88%; + } +} diff --git a/client/src/theme/borders.scss b/client/src/theme/borders.scss new file mode 100644 index 0000000..e5393fa --- /dev/null +++ b/client/src/theme/borders.scss @@ -0,0 +1,9 @@ +:root { + --theme-border: 0.5px solid currentColor; + --theme-border-radius: 17rem 1rem 16rem 1rem/1rem 16rem 1rem 17rem; + --theme-border-radius--subtle: 4.25rem 0.25rem 4rem 0.25rem/0.25rem 4rem + 0.25rem 4.25rem; + --theme-border--thick: 2px solid; + --theme-border--highlight: 0.5px solid var(--theme-color--paper); + --theme-border--underline: 0.5px solid var(--theme-color--ink); +} diff --git a/client/src/theme/colors.scss b/client/src/theme/colors.scss new file mode 100644 index 0000000..615e149 --- /dev/null +++ b/client/src/theme/colors.scss @@ -0,0 +1,18 @@ +:root { + --theme-color--paper: rgb(255, 255, 255); + --theme-color--ink: rgba(12, 12, 50, 1); + --theme-color--ink-fade: rgba(12, 12, 50, 0.25); + --theme-color--accent: rgba(129, 67, 201, 1); + --theme-color--shade: rgba(129, 67, 201, 0.4); + --theme-color--brand: #e62a2a; + --theme-color--pop: rgb(252, 92, 125); + + // @media (prefers-color-scheme: dark) { + // --theme-color--paper: rgba(12, 12, 50, 1); + // --theme-color--ink: rgba(240, 240, 240, 1); + // --theme-color--ink-fade: rgba(240, 240, 240, 0.25); + // --theme-color--pop: rgba(129, 67, 201, 1); + // --theme-color--shade: rgba(252, 92, 125, 0.4); + // --theme-color--accent: rgb(93 223 253); + // } +} diff --git a/client/src/theme/fonts.scss b/client/src/theme/fonts.scss new file mode 100644 index 0000000..67ff9b6 --- /dev/null +++ b/client/src/theme/fonts.scss @@ -0,0 +1,5 @@ +:root { + --theme-font--display: "Roboto Mono", Monaco, monospace; + --theme-font--copy: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue"; +} diff --git a/client/src/theme/functions.scss b/client/src/theme/functions.scss new file mode 100644 index 0000000..fa45969 --- /dev/null +++ b/client/src/theme/functions.scss @@ -0,0 +1,7 @@ +// @use "sass:math"; + +// You can use math in SASS +// https://sass-lang.com/documentation/modules/math + +// And functions that return a value +// https://sass-lang.com/documentation/values/functions diff --git a/client/src/theme/global.scss b/client/src/theme/global.scss new file mode 100644 index 0000000..4df09f5 --- /dev/null +++ b/client/src/theme/global.scss @@ -0,0 +1,14 @@ +// utils, or abstracts, also importable into components +@import "./utilities.scss"; + +// css variables made available globally so you can call them in any component +// you can redefine them inside a css class for that component and it won't leak out +@import "./colors.scss"; +@import "./fonts.scss"; +@import "./type-scale.scss"; +@import "./spacing.scss"; +@import "./borders.scss"; + +//global html styles +@import "./base.scss"; +@import "./invisible.scss"; diff --git a/client/src/theme/invisible.scss b/client/src/theme/invisible.scss new file mode 100644 index 0000000..301fe5a --- /dev/null +++ b/client/src/theme/invisible.scss @@ -0,0 +1,15 @@ +// this makes text invisible but accessible to screenreaders, unlike display:none + +.invisible { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; /* 1 */ + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; /* 2 */ + height: 1px !important; + margin: -1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; +} diff --git a/client/src/theme/mixins.scss b/client/src/theme/mixins.scss new file mode 100644 index 0000000..7715f14 --- /dev/null +++ b/client/src/theme/mixins.scss @@ -0,0 +1,11 @@ +// nothing imported into this file can create any css output on its own +// you can import this into any component to make sass features available to them + +@import "mixins/dots.scss"; +@import "mixins/gradient.scss"; +@import "mixins/grid.scss"; +@import "mixins/invert.scss"; +@import "mixins/menu-transforms.scss"; +@import "mixins/on-event.scss"; +@import "mixins/on-move.scss"; +@import "mixins/screen.scss"; diff --git a/client/src/theme/mixins/dots.scss b/client/src/theme/mixins/dots.scss new file mode 100644 index 0000000..c823853 --- /dev/null +++ b/client/src/theme/mixins/dots.scss @@ -0,0 +1,4 @@ +@mixin dots($color: currentColor, $size: 1px, $spacing: 10px) { + background-image: radial-gradient($color $size, transparent $size); + background-size: $spacing $spacing; +} diff --git a/client/src/theme/mixins/gradient.scss b/client/src/theme/mixins/gradient.scss new file mode 100644 index 0000000..b24e672 --- /dev/null +++ b/client/src/theme/mixins/gradient.scss @@ -0,0 +1,6 @@ +@mixin gradient( + $from: var(--theme-color--pop), + $to: var(--theme-color--accent) +) { + background-image: linear-gradient(to right, $from, $to); +} diff --git a/client/src/theme/mixins/grid.scss b/client/src/theme/mixins/grid.scss new file mode 100644 index 0000000..ad2bb1b --- /dev/null +++ b/client/src/theme/mixins/grid.scss @@ -0,0 +1,9 @@ +// https://css-tricks.com/code-as-documentation-new-strategies-with-css-grid/ +@mixin grid-assign($elements...) { + display: grid; + @each $element in $elements { + &__#{$element} { + grid-area: $element; + } + } +} diff --git a/client/src/theme/mixins/invert.scss b/client/src/theme/mixins/invert.scss new file mode 100644 index 0000000..05f773d --- /dev/null +++ b/client/src/theme/mixins/invert.scss @@ -0,0 +1,4 @@ +@mixin invert { + background: var(--theme-color--ink); + color: var(--theme-color--paper); +} diff --git a/client/src/theme/mixins/menu-transforms.scss b/client/src/theme/mixins/menu-transforms.scss new file mode 100644 index 0000000..fb84967 --- /dev/null +++ b/client/src/theme/mixins/menu-transforms.scss @@ -0,0 +1,37 @@ +@mixin menu-transforms { + transition: transform 0.55s 0.3s ease-in-out; + + &:before, + &:after { + transition: all 0.3s ease-in-out; + content: " "; + display: block; + } + &:before { + width: unset; + height: unset; + left: 0; + right: 0; + top: 1em; + bottom: 1em; + } + &:after { + top: 0; + bottom: unset; + box-shadow: 0 2em currentColor; + } + &:hover, + &:focus { + transform: rotate(45deg); + &:before { + left: calc(50% - 0.1em); + right: calc(50% - 0.1em); + top: -0.8em; + bottom: -0.8em; + } + &:after { + top: 1em; + box-shadow: 0 0px transparent; + } + } +} diff --git a/client/src/theme/mixins/offset-dots.scss b/client/src/theme/mixins/offset-dots.scss new file mode 100644 index 0000000..e49ef86 --- /dev/null +++ b/client/src/theme/mixins/offset-dots.scss @@ -0,0 +1,25 @@ +@mixin offset-dots($offset: 0.27em) { + position: relative; + overflow: visible; + + &:before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: block; + width: 100%; + height: 100%; + z-index: -1; + transition: transform 0.3s; + background-image: radial-gradient(currentColor 1px, transparent 1px); + background-size: calc(10 * 1px) calc(10 * 1px); + + @if $offset != null { + @include offset($offset); + } + @content; + } +} diff --git a/client/src/theme/mixins/on-event.scss b/client/src/theme/mixins/on-event.scss new file mode 100644 index 0000000..64627cf --- /dev/null +++ b/client/src/theme/mixins/on-event.scss @@ -0,0 +1,48 @@ +/// Event wrapper +/// @author Harry Roberts +/// @param {Bool} $self [false] - Whether or not to include current selector +/// @link https://twitter.com/csswizardry/status/478938530342006784 Original tweet from Harry Roberts +// hover broken out because of https://css-tricks.com/annoying-mobile-double-tap-link-issue/ + +// enforces focus styling + +@mixin on-event($self: false, $root: null) { + @if $self { + &, + &.is-active, + &:active, + &:focus { + @content; + } + + @media (hover) { + &:hover { + @content; + } + } + } + @elseif $root { + #{$root}.is-active &, + #{$root}:active &, + #{$root}:focus & { + @content; + } + + #{$root}:hover & { + @media (hover) { + @content; + } + } + } @else { + &.is-active, + &:active, + &:focus { + @content; + } + @media (hover) { + &:hover { + @content; + } + } + } +} diff --git a/client/src/theme/mixins/on-move.scss b/client/src/theme/mixins/on-move.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/src/theme/mixins/screen.scss b/client/src/theme/mixins/screen.scss new file mode 100644 index 0000000..e69de29 diff --git a/client/src/theme/spacing.scss b/client/src/theme/spacing.scss new file mode 100644 index 0000000..5ab287a --- /dev/null +++ b/client/src/theme/spacing.scss @@ -0,0 +1,10 @@ +$spacing__levels: 1, 2, 3, 4, 5, 6; +:root { + @each $size in $spacing__levels { + --theme-spacing--#{$size}: clamp( + #{($size * 2) * 1px}, + #{$size}vw + #{$size}px, + #{$size * 10px} + ); + } +} diff --git a/client/src/theme/type-scale.scss b/client/src/theme/type-scale.scss new file mode 100644 index 0000000..c01342c --- /dev/null +++ b/client/src/theme/type-scale.scss @@ -0,0 +1,8 @@ +:root { + --theme-type-size--6: clamp(0.83rem, 0.44vw + 0.72rem, 1rem); + --theme-type-size--5: clamp(1rem, 0.53vw + 0.87rem, 1.2rem); + --theme-type-size--4: clamp(1.2rem, 0.64vw + 1.04rem, 1.44rem); + --theme-type-size--3: clamp(1.44rem, 0.77vw + 1.25rem, 1.73rem); + --theme-type-size--2: clamp(1.73rem, 0.92vw + 1.5rem, 2.07rem); + --theme-type-size--1: clamp(2.07rem, 1.11vw + 1.8rem, 2.49rem); +} diff --git a/client/src/theme/utilities.scss b/client/src/theme/utilities.scss new file mode 100644 index 0000000..f48190b --- /dev/null +++ b/client/src/theme/utilities.scss @@ -0,0 +1,6 @@ +// lists, maps, sass-only variables, mixins, functions +// nothing imported into this file can create any css output on its own +// you can import this into any component to make sass features available to them + +@import "./mixins.scss"; +@import "./functions.scss";