diff --git a/.gitignore b/.gitignore
index 37e1e056e527bdc2fca77542fd35dee10f111b04..7243b96427cd1dee4410b058a550a2ecf6537442 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,7 @@
 bundle.js
 *.map
 *.css.map
-src/**/*.css
+*.css
 node_modules
 target
 .env
diff --git a/Makefile b/Makefile
index 637aa2cb19cd0a1dd1469d9a330b57a42327df8b..61dd6ea492e6d26eb24371a49b0c9bcd6335f5e7 100644
--- a/Makefile
+++ b/Makefile
@@ -23,10 +23,10 @@ bash-api:
 	docker exec -it kuadrado_server bash
 
 build-website:
-	npm run --prefix ./website build
+	npm run --prefix ./website build-prod
 
 build-website-debug:
-	npm run --prefix ./website build debug
+	npm run --prefix ./website build
 
 build-admin:
 	npm run --prefix ./admin-frontend build
diff --git a/admin-frontend/src/components/create-article-form.js b/admin-frontend/src/components/create-article-form.js
index e7651c0d4883e24c905110f162e39dda1e21f11a..494687dfe9d13555cd1a693eddb6753b6b74a08a 100644
--- a/admin-frontend/src/components/create-article-form.js
+++ b/admin-frontend/src/components/create-article-form.js
@@ -193,7 +193,7 @@ class CreateArticleForm {
                 maxWidth: "800px",
             },
             contents: [
-                { tag: "button", contents: "RESET", onclick: this.reset.bind(this) },
+                !this.params.data && { tag: "button", contents: "RESET", onclick: this.reset.bind(this) },
                 { tag: "h2", contents: article.title },
                 { tag: "h4", contents: article.subtitle },
                 { tag: "p", contents: article.body.replace(/\n/g, "<br>") },
diff --git a/admin-frontend/src/components/update-article-form.js b/admin-frontend/src/components/update-article-form.js
index 67994d3fdf82a9e56da2172634b363e4e996f7b6..967c4d04bdcbb9cc1faa883c127fe7ae9f81dd2b 100644
--- a/admin-frontend/src/components/update-article-form.js
+++ b/admin-frontend/src/components/update-article-form.js
@@ -9,7 +9,12 @@ class UpdateArticleForm {
         this.state = {
             search_article_title: "",
             article_to_update: {},
-        }
+        };
+
+        this.articles_list = new ArticleList({
+            on_select_article: this.handle_select_article.bind(this),
+            on_delete_result: this.handle_delete_article.bind(this)
+        });
     }
 
     reset() {
@@ -39,38 +44,6 @@ class UpdateArticleForm {
             .catch(err => alert(err))
     }
 
-    refresh_search_result() {
-        obj2htm.subRender(
-            this.render_search_result(),
-            document.getElementById("update-article-form-search-result"),
-            { mode: "replace" },
-        );
-    }
-
-    render_search_result() {
-        const { search_result } = this.state;
-        return {
-            tag: "div",
-            id: "update-article-form-search-result",
-            style_rules: {
-                display: "flex",
-                gap: "10px",
-                alignItems: "center"
-            },
-            contents: search_result.title ? [
-                { tag: "strong", contents: search_result.title },
-                {
-                    tag: "button", contents: "SELECT",
-                    onclick: this.handle_select_result.bind(this)
-                },
-                {
-                    tag: "button", contents: "DELETE",
-                    onclick: this.handle_delete_article.bind(this)
-                }
-            ] : []
-        }
-    }
-
     refresh_update_form() {
         obj2htm.subRender(
             this.render_update_form(),
@@ -88,7 +61,7 @@ class UpdateArticleForm {
                     data: this.state.article_to_update,
                     on_article_sent: () => {
                         this.reset();
-                        this.refresh_search_result();
+                        this.articles_list.refresh_list();
                     }
                 }).render()]
                 : []
@@ -110,10 +83,7 @@ class UpdateArticleForm {
                 maxWidth: "800px",
             },
             contents: [
-                new ArticleList({
-                    on_select_article: this.handle_select_article.bind(this),
-                    on_delete_result: this.handle_delete_article.bind(this)
-                }).render(),
+                this.articles_list.render(),
                 { tag: "hr", style_rules: { width: "100%" } },
                 this.render_update_form(),
             ]
diff --git a/public/assets/images/screen_mentalo_app.png b/public/assets/images/screen_mentalo_app.png
new file mode 100644
index 0000000000000000000000000000000000000000..5220d25f95dba8e4d2c5fdb3f45a4cd9fcff4931
Binary files /dev/null and b/public/assets/images/screen_mentalo_app.png differ
diff --git a/public/assets/translations/en.json b/public/assets/translations/en.json
index 7dbe0401ad090f13686d140bc20ce64410f3a334..ad1ff38773a36f1bc9069bd8058aebdaa7d66f0c 100644
--- a/public/assets/translations/en.json
+++ b/public/assets/translations/en.json
@@ -29,5 +29,7 @@
     "edu-learn-mentalo": "Create a game in a few sessions with the Mentalo application. Handle logical, narrative and artistic concepts with maximum simplicity.",
     "Me contacter": "Contact me",
     "Pour s'inscrire ou en savoir plus": "To register or find out more",
-    "software-page-intro": "R&D, experimental projects, software tools for game development or for the web."
+    "software-page-intro": "R&D, experimental projects, software tools for game development or for the web.",
+    "Programme XXXX": "{%date%} program",
+    "Rien de prévu pour le moment": "Nothing planned for the moment"
 }
\ No newline at end of file
diff --git a/public/assets/translations/fr.json b/public/assets/translations/fr.json
index fdcaa31909af1067f2f13dbe733496b97e59ed30..b2aea9cb2fa0ff62530b6a7e93aabd8aa9076047 100644
--- a/public/assets/translations/fr.json
+++ b/public/assets/translations/fr.json
@@ -12,5 +12,6 @@
     "edu-learn-computer": "Perdu avec votre ordinateur ou votre smartphone, les logiciels, internet ? Prenez en main les fondamentaux apprenez pas à pas à utiliser sereinement la technologie.",
     "edu-learn-gnu": "<b>Passez le cap du libre ! </b><br/>Apprenez à installer Linux, faites vos premiers pas avec les logiciels libres et acquérez une autonomie suffisante pour une utilisation basique.",
     "edu-learn-mentalo": "Créez un jeu en quelques séances avec l'application Mentalo. Manipulez des concepts logiques, narratifs et artistiques avec le maximum de simplicité.",
-    "software-page-intro": "R&D, projets expérimentaux, outillage logiciel pour le développement de jeu ou pour le web."
+    "software-page-intro": "R&D, projets expérimentaux, outillage logiciel pour le développement de jeu ou pour le web.",
+    "Programme XXXX": "Programme {%date%}"
 }
\ No newline at end of file
diff --git a/public/education/education.js b/public/education/education.js
index 8207c7e067fad0161f321ccd291908f1a0f00a0d..566910037b310b1c3156d9fe2e7923594227f527 100644
--- a/public/education/education.js
+++ b/public/education/education.js
@@ -1,879 +1,2 @@
-(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
-module.exports = {
-    build: {
-        protected_dirs: ["assets", "style", "views", "standard"],
-        default_meta_keys: ["title", "description", "image", "open_graph", "json_ld"],
-    },
-};
-
-},{}],2:[function(require,module,exports){
-module.exports = {
-    images_url: `/assets/images`,
-    data_url: `/assets/data`,
-    translations_url: "/assets/translations"
-};
-
-},{}],3:[function(require,module,exports){
-"use strict";
-/**
- * A tiny library to handle text translation.
- */
-
-/**
- * init parameter object type
- * @typedef TranslatorParam
- * @property {String[]} supported_languages - DEFAULT: ["en"] - An array of ISO 639 lowercase language codes
- * @property {String} locale ISO 639 lowercase language code - DEFAULT: "en"
- *
- * @property {Boolean} use_url_locale_fragment - DEFAULT: true -
- * If true, the 1st fragment of the window.location url will be parsed as a language code
- * Example:
- * window.location
- * > https://example.com/en/some-page...
- * then the /en/ part of the url will be taken in priority and locale will be set to "en".
- * window.location
- * > https://example.com/some-page...
- * No locale fragment will be found so locale will not be set from url fragment.
- * window.location
- * > https://example.com/some-page/en
- * Doesn't work, locale fragment must be the first framgment
- *
- * @property {String} local_storage_key  - DEFAULT: "translator-prefered-language"
- *  The key used to saved the current locale into local storage
- *
- * @property {String} translations_url - REQUIRED - the url of the directory containing the static json files for translations
- * Translations files are expected to be named with their corresponding locale code.
- * Example:
- * if supported_languages is set to ["en", "fr", "it"]
- * and translations_url is set to "https://example.com/translations/""
- * Then the expected translations files are
- * https://example.com/translations/en.json
- * https://example.com/translations/fr.json
- * https://example.com/translations/it.json
- * The json resources must simple key value maps, value being the translated text.
- */
-
-module.exports = {
-    locale: "en", // ISO 639 lowercase language code
-    supported_languages: ["en"],
-    translations: {},
-    translations_url: "",
-    use_url_locale_fragment: true,
-    local_storage_key: "translator-prefered-language",
-
-    /**
-     * Initialize the lib with params
-     * @param {TranslatorParam} params 
-     * @returns {Promise}
-     */
-    init(params) {
-        Object.entries(params).forEach(k_v => {
-            const [key, value] = k_v;
-            if ([
-                "supported_languages",
-                "use_url_locale_fragment",
-                "local_storage_key",
-                "translations_url"
-            ].includes(key)) {
-                this[key] = value;
-            }
-        });
-
-        this.translations_url = this.format_translations_url(this.translations_url);
-        this.supported_languages = this.format_supported_languages(this.supported_languages);
-
-        return new Promise((resolve, reject) => {
-            const loc =
-                (() => {// Locale from url priority 1
-                    if (this.use_url_locale_fragment) {
-                        const first_url_fragment = window.location.pathname.substring(1).split("/")[0];
-                        const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-                        return fragment_is_locale ? first_url_fragment : "";
-                    } else {
-                        return "";
-                    }
-                })()
-                || localStorage.getItem(this.local_storage_key) // Locale from storage priority 2
-                || (() => { // locale from navigator priority 3
-                    const navigator_locale = navigator.language.split("-")[0].toLocaleLowerCase();
-                    return this.supported_languages.includes(navigator_locale)
-                        ? navigator_locale
-                        : this.supported_languages[0] // Default if navigator locale is not supported
-                })();
-
-            fetch(`${this.translations_url}${loc}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.locale = loc;
-                    this.translations = response;
-                    resolve();
-                })
-                .catch(err => {
-                    this.locale = "en";
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Return a lowercase string without dash.
-     * If given locale is en-EN, then "en" will be returned.
-     * @param {String} locale 
-     * @returns A lowercase string
-     */
-    format_locale(locale) {
-        return locale.split("-")[0].toLowerCase();
-    },
-
-    /**
-     * Appends a slash at the end of the given string if missing, and returns the url.
-     * @param {String} url 
-     * @returns {String}
-     */
-    format_translations_url(url) {
-        if (url.charAt(url.length - 1) !== "/") {
-            url += "/"
-        }
-        return url;
-    },
-
-    /**
-     * Return the array of language codes formatted as lowsercase language code.
-     * if ["en-EN", "it-IT"]is given, ["en", "it"] will b returned.
-     * @param {String[]} languages_codes 
-     * @returns {String[]}
-     */
-    format_supported_languages(languages_codes) {
-        return languages_codes.map(lc => this.format_locale(lc));
-    },
-
-    /**
-     * Fetches a new set of translations in case the wanted language changes
-     * @param {String} locale A lowercase language code
-     * @returns {Promise}
-     */
-    update_translations(locale) {
-        locale = this.format_locale(locale);
-        return new Promise((resolve, reject) => {
-            fetch(`${this.translations_url}${locale}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.translations = response;
-                    this.locale = locale;
-
-                    localStorage.setItem(this.local_storage_key, locale);
-
-                    const split_path = window.location.pathname.substring(1).split("/");
-                    const first_url_fragment = split_path[0];
-                    const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-
-                    if (fragment_is_locale) {
-                        split_path.splice(0, 1, locale);
-                        const updated_path = split_path.join("/");
-                        window.history.replaceState(null, "", "/" + updated_path);
-                    }
-                    resolve();
-                })
-                .catch(err => {
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Tries to get the translation of the source string, or return the string as it.
-     * @param {String} text The source text to translate
-     * @param {Object} params Some dynamic element to insert in the text
-     * Params can be used if the translated text provide placeholder like {%some_word%}
-     * Example:
-     * translator.trad("Some trad key", {some_word: "a dynamic parameter"})
-     * -> translation for "Some trad key": "A translated text with {%some_word%}"
-     * -> will be return as : "A translated text with a dynamic parameter"
-     * @returns {String}
-     */
-    trad: function (text, params = {}) {
-        text = this.translations[text] || text;
-
-        Object.keys(params).forEach(k => {
-            text = text.replace(`{%${k}%}`, params[k]);
-        });
-
-        return text;
-    }
-};
-},{}],4:[function(require,module,exports){
-"use strict";
-
-module.exports = {
-    register_key: "objectToHtmlRender",
-
-    /**
-     * Register "this" as a window scope accessible variable named by the given key, or default.
-     * @param {String} key 
-     */
-    register(key) {
-        const register_key = key || this.register_key;
-        window[register_key] = this;
-    },
-
-    /**
-     * This must be called before any other method in order to initialize the lib.
-     * It provides the root of the rendering cycle as a Javascript object.
-     * @param {Object} renderCycleRoot A JS component with a render method.
-     */
-    setRenderCycleRoot(renderCycleRoot) {
-        this.renderCycleRoot = renderCycleRoot;
-    },
-
-    event_name: "objtohtml-render-cycle",
-
-    /**
-     * Set a custom event name for the event that is trigger on render cycle.
-     * Default is "objtohtml-render-cycle".
-     * @param {String} evt_name 
-     */
-    setEventName(evt_name) {
-        this.event_name = evt_name;
-    },
-
-    /**
-     * This is the core agorithm that read an javascript Object and convert it into an HTML element.
-     * @param {Object} obj The object representing the html element must be formatted like:
-     * {
-     *      tag: String // The name of the html tag, Any valid html tag should work. div, section, br, ul, li...
-     *      xmlns: String // This can replace the tag key if the element is an element with a namespace URI, for example an <svg> tag.
-     *                      See https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS for more information
-     *      style_rules: Object // a object providing css attributes. The attributes names must be in JS syntax,
-     *                              like maxHeight: "500px", backgrouncColor: "#ff2d56",  margin: 0,  etc.
-     *      contents: Array or String // This reprensents the contents that will be nested in the created html element.
-     *                                   <div>{contents}</div>
-     *                                   The contents can be an array of other objects reprenting elements (with tag, contents, etc)
-     *                                   or it can be a simple string.
-     *      // All other attributes will be parsed as html attributes. They can be anything like onclick, href, onchange, title...
-     *      // or they can also define custom html5 attributes, like data, my_custom_attr or anything.
-     * }
-     * @returns {HTMLElement} The output html node.
-     */
-    objectToHtml(obj) {
-        if (!obj) return document.createElement("span"); // in case of invalid input, don't block the whole process.
-        const objectToHtml = this.objectToHtml.bind(this);
-        const { tag, xmlns } = obj;
-        const node = xmlns !== undefined ? document.createElementNS(xmlns, tag) : document.createElement(tag);
-        const excludeKeys = ["tag", "contents", "style_rules", "state", "xmlns"];
-
-        Object.keys(obj)
-            .filter(attr => !excludeKeys.includes(attr))
-            .forEach(attr => {
-                switch (attr) {
-                    case "class":
-                        node.classList.add(...obj[attr].split(" ").filter(s => s !== ""));
-                        break;
-                    case "on_render":
-                        if (!obj.id) {
-                            node.id = `${btoa(JSON.stringify(obj).slice(0, 127)).replace(/\=/g, '')}${window.performance.now()}`;
-                        }
-                        if (typeof obj.on_render !== "function") {
-                            console.error("The on_render attribute must be a function")
-                        } else {
-                            this.attach_on_render_callback(node, obj.on_render);
-                        }
-                        break;
-                    default:
-                        if (xmlns !== undefined) {
-                            node.setAttributeNS(null, attr, obj[attr])
-                        } else {
-                            node[attr] = obj[attr];
-                        }
-                }
-            });
-        if (obj.contents && typeof obj.contents === "string") {
-            node.innerHTML = obj.contents;
-        } else {
-            obj.contents &&
-                obj.contents.length > 0 &&
-                obj.contents.forEach(el => {
-                    switch (typeof el) {
-                        case "string":
-                            node.innerHTML = el;
-                            break;
-                        case "object":
-                            if (xmlns !== undefined) {
-                                el = Object.assign(el, { xmlns })
-                            }
-                            node.appendChild(objectToHtml(el));
-                            break;
-                    }
-                });
-        }
-
-        if (obj.style_rules) {
-            Object.keys(obj.style_rules).forEach(rule => {
-                node.style[rule] = obj.style_rules[rule];
-            });
-        }
-
-        return node;
-    },
-
-    on_render_callbacks: [],
-
-    /**
-     * This is called if the on_render attribute of a component is set.
-     * @param {HTMLElement} node The created html element
-     * @param {Function} callback The callback defined in the js component to render
-     */
-    attach_on_render_callback(node, callback) {
-        const callback_handler = {
-            callback: e => {
-                if (e.detail.outputNode === node || e.detail.outputNode.querySelector(`#${node.id}`)) {
-                    callback(node);
-                    const handler_index = this.on_render_callbacks.indexOf((this.on_render_callbacks.find(cb => cb.node === node)));
-                    if (handler_index === -1) {
-                        console.warn("A callback was registered for node with id " + node.id + " but callbacck handler is undefined.")
-                    } else {
-                        window.removeEventListener(this.event_name, this.on_render_callbacks[handler_index].callback)
-                        this.on_render_callbacks.splice(handler_index, 1);
-                    }
-                }
-            },
-            node,
-        };
-
-        const len = this.on_render_callbacks.push(callback_handler);
-        window.addEventListener(this.event_name, this.on_render_callbacks[len - 1].callback);
-    },
-
-    /**
-     * If a main element exists in the html document, it will be used as rendering root.
-     * If not, it will be created and inserted.
-     */
-    renderCycle: function () {
-        const main_elmt = document.getElementsByTagName("main")[0] || (function () {
-            const created_main = document.createElement("main");
-            document.body.appendChild(created_main);
-            return created_main;
-        })();
-
-        this.subRender(this.renderCycleRoot.render(), main_elmt, { mode: "replace" });
-    },
-
-    /**
-     * This method behaves like the renderCycle() method, but rather that starting the rendering cycle from the root component,
-    * it can start from any component of the tree. The root component must be given as the first argument, the second argument be
-    * be a valid html element in the dom and will be used as the insertion target.
-     * @param {Object} object An object providing a render method returning an object representation of the html to insert
-     * @param {HTMLElement} htmlNode The htlm element to update
-     * @param {Object} options can be used the define the insertion mode, default is set to "append" and can be set to "override",
-         * "insert-before" (must be defined along with an insertIndex key (integer)),
-         * "adjacent" (must be defined along with an insertLocation key (String)), "replace" or "remove".
-         * In case of "remove", the first argument "object" is not used and can be set to null, undefined or {}...
-     */
-    subRender(object, htmlNode, options = { mode: "append" }) {
-        let outputNode = null;
-
-        const get_insert = () => {
-            outputNode = this.objectToHtml(object);
-            return outputNode;
-        };
-
-        switch (options.mode) {
-            case "append":
-                htmlNode.appendChild(get_insert());
-                break;
-            case "override":
-                htmlNode.innerHTML = "";
-                htmlNode.appendChild(get_insert());
-                break;
-            case "insert-before":
-                htmlNode.insertBefore(get_insert(), htmlNode.childNodes[options.insertIndex]);
-                break;
-            case "adjacent":
-                /**
-                 * options.insertLocation must be one of:
-                 *
-                 * afterbegin
-                 * afterend
-                 * beforebegin
-                 * beforeend
-                 */
-                htmlNode.insertAdjacentHTML(options.insertLocation, get_insert());
-                break;
-            case "replace":
-                htmlNode.parentNode.replaceChild(get_insert(), htmlNode);
-                break;
-            case "remove":
-                htmlNode.remove();
-                break;
-        }
-        const evt_name = this.event_name;
-        const event = new CustomEvent(evt_name, {
-            detail: {
-                inputObject: object,
-                outputNode,
-                insertOptions: options,
-                targetNode: htmlNode,
-            }
-        });
-
-        window.dispatchEvent(event);
-    },
-};
-},{}],5:[function(require,module,exports){
-"use strict";
-const translator = require("ks-cheap-translator");
-const { translations_url } = require("../../constants");
-
-class WebPage {
-    constructor(args) {
-        Object.assign(this, args);
-
-        if (!this.id) {
-            this.id = "webpage-" + performance.now();
-        }
-
-        translator.init({
-            translations_url,
-            supported_languages: ["fr", "en"],
-        }).then(this.refresh_all.bind(this));
-    }
-
-    refresh() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), { mode: "replace" })
-    }
-
-    refresh_all() {
-        obj2htm.renderCycle()
-    }
-}
-
-module.exports = WebPage;
-},{"../../constants":2,"ks-cheap-translator":3}],6:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const WebPage = require("../../lib/web-page");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-const EDU_THEMES = [
-    {
-        title: "Programmation",
-        description: "edu-learn-coding",
-        image: "learning_theme_coding.png",
-    },
-    {
-        title: "Dessin numérique et animation 2D",
-        description: "edu-learn-2d",
-        image: "learning_theme_2d.png",
-    },
-    {
-        title: "Maths et physique",
-        description: "edu-learn-math",
-        image: "learning_theme_math.png",
-    },
-    {
-        title: "Aide informatique générale",
-        description: "edu-learn-computer",
-        image: "learning_theme_pc.png",
-    },
-    {
-        title: "Stage GNU/Linux",
-        description: "edu-learn-gnu",
-        image: "learning_theme_linux.png"
-    },
-    {
-        title: "Créer un jeu avec Mentalo",
-        description: "edu-learn-mentalo",
-        image: "learning_theme_mentalo.png",
-    }
-];
-
-class EducationPage extends WebPage {
-    render() {
-        return {
-            tag: "div",
-            id: "education-page",
-            typeof: "EducationalOrganization",
-            contents: [
-                {
-                    tag: "div",
-                    class: "page-header logo-left",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "page-contents-center grid-wrapper",
-                            contents: [
-                                {
-                                    tag: "div",
-                                    class: "logo",
-                                    contents: [
-                                        {
-                                            tag: "img",
-                                            alt: "image brain",
-                                            src: `${images_url}/brain.svg`,
-                                        },
-                                    ],
-                                },
-                                { tag: "h1", contents: t("Pédagogie") },
-                                {
-                                    tag: "p",
-                                    contents: t("edu-page-intro"),
-                                },
-                            ],
-                        },
-                    ],
-                },
-                {
-                    tag: "div",
-                    class: "title-banner",
-                },
-                {
-                    tag: "section",
-                    class: "bg-dark",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "page-contents-center",
-                            contents: [
-                                {
-                                    tag: "ul",
-                                    class: "edu-themes",
-                                    contents: EDU_THEMES.map(theme => {
-                                        return {
-                                            tag: "li",
-                                            class: "edu-theme",
-                                            contents: [
-                                                { tag: "img", width: 250, height: 140, class: "pixelated", src: `${images_url}/${theme.image}` },
-                                                { tag: "h3", contents: t(theme.title) },
-                                                { tag: "p", contents: t(theme.description) },
-                                            ]
-                                        }
-                                    })
-                                },
-                            ]
-                        },
-                    ]
-                },
-                {
-                    tag: "section",
-                    class: "practical-info",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "page-contents-center",
-                            contents: [
-                                {
-                                    tag: "div",
-                                    class: "info-block",
-                                    contents: [
-                                        { tag: "h3", class: "info-title", contents: `${t("Pour s'inscrire ou en savoir plus")} <em>(programme 2022 à définir, plus d'infos bientôt)</em>` },
-                                        {
-                                            tag: "ul",
-                                            class: "info-body",
-                                            contents: [
-                                                {
-                                                    tag: "li",
-                                                    contents: [
-                                                        { tag: "span", contents: t("Me contacter") },
-                                                        {
-                                                            tag: "a",
-                                                            href: "mailto:contact@kuadrado-software.fr",
-                                                            contents: "contact@kuadrado-software.fr",
-                                                        }
-                                                    ]
-                                                },
-                                                {
-                                                    tag: "li",
-                                                    contents: [
-                                                        { tag: "span", contents: "" },
-                                                        {
-                                                            tag: "a",
-                                                            href: "tel:+33475780872",
-                                                            contents: "04 75 78 08 72",
-                                                            property: "telephone",
-                                                        },
-                                                    ]
-                                                },
-                                            ]
-                                        }
-                                    ]
-                                }
-                            ]
-                        }
-                    ]
-
-                },
-            ],
-        };
-    }
-}
-
-module.exports = EducationPage;
-
-},{"../../../constants":2,"../../lib/web-page":5,"ks-cheap-translator":3}],7:[function(require,module,exports){
-"use strict";
-const runPage = require("../../run-page");
-const EducationPage = require("./education");
-runPage(EducationPage);
-
-},{"../../run-page":8,"./education":6}],8:[function(require,module,exports){
-"use strict";
-
-const renderer = require("object-to-html-renderer")
-const Template = require("./template/template");
-
-module.exports = function runPage(PageComponent) {
-    const template = new Template({ page: new PageComponent() });
-    renderer.register("obj2htm")
-    obj2htm.setRenderCycleRoot(template);
-    obj2htm.renderCycle();
-};
-
-},{"./template/template":10,"object-to-html-renderer":4}],9:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-const NAV_MENU_ITEMS = [
-    { url: "/games/", text: "Jeux" },
-    {
-        url: "/education/",
-        text: "Pédagogie",
-    },
-    { url: "/software-development/", text: "Software" }
-];
-
-class NavBar {
-    constructor() {
-        this.initEventHandlers();
-    }
-
-    handleBurgerClick() {
-        document.getElementById("nav-menu-list").classList.toggle("responsive-show");
-    }
-
-    initEventHandlers() {
-        window.addEventListener("click", event => {
-            if (
-                event.target.id !== "nav-menu-list" &&
-                !event.target.classList.contains("burger") &&
-                !event.target.parentNode.classList.contains("burger")
-            ) {
-                document.getElementById("nav-menu-list").classList.remove("responsive-show");
-            }
-        });
-    }
-
-    handle_chang_lang(lang) {
-        translator.update_translations(lang).then(() => {
-            obj2htm.renderCycle();
-        }).catch(err => console.log(err));
-    }
-
-    renderHome() {
-        return {
-            tag: "div",
-            class: "home",
-            contents: [
-                {
-                    tag: "a",
-                    href: "/",
-                    contents: [
-                        {
-                            tag: "img",
-                            alt: "Logo Kuadrado",
-                            src: `${images_url}/logo_kuadrado.svg`,
-                        },
-                        {
-                            tag: "img",
-                            alt: "Kuadrado Software",
-                            class: "logo-text",
-                            src: `${images_url}/logo_kuadrado_txt.svg`,
-                        },
-                    ],
-                },
-            ],
-        };
-    }
-
-    renderMenu(menuItemsArray, isSubmenu = false, parentUrl = "") {
-        return {
-            tag: "ul",
-            id: "nav-menu-list",
-            class: isSubmenu ? "submenu" : "",
-            contents: menuItemsArray.map(item => {
-                const { url, text, submenu } = item;
-                const href = `${parentUrl}${url}`;
-                return {
-                    tag: "li",
-                    class: !isSubmenu && window.location.pathname === href ? "active" : "",
-                    contents: [
-                        {
-                            tag: "a",
-                            href,
-                            contents: t(text),
-                        },
-                    ].concat(submenu ? [this.renderMenu(submenu, true, url)] : []),
-                };
-            }).concat({
-                tag: "li",
-                class: "lang-flags",
-                contents: ["fr", "en"].map(lang => {
-                    return {
-                        tag: "img", src: `${images_url}/flag-${lang}.svg`,
-                        class: translator.locale === lang ? "selected" : "",
-                        onclick: this.handle_chang_lang.bind(this, lang)
-                    }
-                })
-            }),
-        };
-    }
-
-    renderResponsiveBurger() {
-        return {
-            tag: "div",
-            class: "burger",
-            onclick: this.handleBurgerClick.bind(this),
-            contents: [{ tag: "span", contents: "···" }],
-        };
-    }
-
-    render() {
-        return {
-            tag: "nav",
-            contents: [
-                this.renderHome(),
-                this.renderResponsiveBurger(),
-                this.renderMenu(NAV_MENU_ITEMS),
-            ],
-        };
-    }
-}
-
-module.exports = NavBar;
-
-},{"../../../constants":2,"ks-cheap-translator":3}],10:[function(require,module,exports){
-"use strict";
-
-const { in_construction } = require("../../config");
-const { images_url } = require("../../constants");
-const NavBar = require("./components/navbar");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator)
-
-class Template {
-    constructor(props) {
-        this.props = props;
-    }
-    render() {
-        return {
-            tag: "main",
-            contents: [
-                {
-                    tag: "header",
-                    contents: [new NavBar().render()],
-                },
-                in_construction && {
-                    tag: "section",
-                    class: "warning-banner",
-                    contents: [
-                        {
-                            tag: "strong",
-                            class: "page-contents-center",
-                            contents: t("Site en construction ..."),
-                        },
-                    ],
-                },
-                {
-                    tag: "section",
-                    id: "page-container",
-                    contents: [this.props.page.render()],
-                },
-                {
-                    tag: "footer",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "logo",
-                            contents: [
-                                {
-                                    tag: "img",
-                                    alt: `logo Kuadrado`,
-                                    src: `${images_url}/logo_kuadrado.svg`,
-                                },
-                                {
-                                    tag: "img",
-                                    class: "text-logo",
-                                    alt: "Kuadrado Software",
-                                    src: `${images_url}/logo_kuadrado_txt.svg`,
-                                },
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: "32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France",
-                        },
-                        {
-                            tag: "div",
-                            contents: [
-                                { tag: "strong", contents: "<blue>Contact : </blue>" },
-                                {
-                                    tag: "a",
-                                    href: "mailto:contact@kuadrado-software.fr",
-                                    contents: "contact@kuadrado-software.fr",
-                                },
-                            ],
-                        },
-                        {
-                            tag: "div",
-                            class: "social",
-                            contents: [
-                                {
-                                    tag: "strong",
-                                    contents: `<blue>${t("Sur les réseaux")} : </blue>`,
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://www.linkedin.com/company/kuadrado-software",
-                                    target: "_blank",
-                                    contents: "in",
-                                    title: "Linkedin",
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://mastodon.gamedev.place/@kuadrado_software",
-                                    target: "_blank",
-                                    contents: "m",
-                                    title: "Mastodon",
-                                }
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: `Copyleft 🄯 ${new Date()
-                                .getFullYear()} Kuadrado Software | 
-                                ${t("kuadrado-footer-copyleft")}`,
-                        },
-                        {
-                            tag: "div", contents: [
-                                { tag: "span", contents: t("Ce site web est") + " " },
-                                {
-                                    tag: "a", target: "_blank",
-                                    style_rules: { fontWeight: "bold" },
-                                    href: "https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",
-                                    contents: "OPEN SOURCE"
-                                }
-                            ]
-                        }
-                    ],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = Template;
-
-},{"../../config":1,"../../constants":2,"./components/navbar":9,"ks-cheap-translator":3}]},{},[7]);
+!function s(a,r,o){function i(t,e){if(!r[t]){if(!a[t]){var n="function"==typeof require&&require;if(!e&&n)return n(t,!0);if(c)return c(t,!0);throw(n=new Error("Cannot find module '"+t+"'")).code="MODULE_NOT_FOUND",n}n=r[t]={exports:{}},a[t][0].call(n.exports,function(e){return i(a[t][1][e]||e)},n,n.exports,s,a,r,o)}return r[t].exports}for(var c="function"==typeof require&&require,e=0;e<o.length;e++)i(o[e]);return i}({1:[function(e,t,n){t.exports={build:{protected_dirs:["assets","style","views","standard"],default_meta_keys:["title","description","image","open_graph","json_ld"]}}},{}],2:[function(e,t,n){t.exports={images_url:"/assets/images",data_url:"/assets/data",translations_url:"/assets/translations"}},{}],3:[function(e,t,n){"use strict";t.exports={locale:"en",supported_languages:["en"],translations:{},translations_url:"",use_url_locale_fragment:!0,local_storage_key:"translator-prefered-language",init(e){return Object.entries(e).forEach(e=>{var[t,e]=e;["supported_languages","use_url_locale_fragment","local_storage_key","translations_url"].includes(t)&&(this[t]=e)}),this.translations_url=this.format_translations_url(this.translations_url),this.supported_languages=this.format_supported_languages(this.supported_languages),new Promise((t,n)=>{const s=(()=>{if(this.use_url_locale_fragment){var e=window.location.pathname.substring(1).split("/")[0];return this.supported_languages.includes(e)?e:""}return""})()||localStorage.getItem(this.local_storage_key)||(e=navigator.language.split("-")[0].toLocaleLowerCase(),this.supported_languages.includes(e)?e:this.supported_languages[0]);var e;fetch(`${this.translations_url}${s}.json`).then(e=>e.json()).then(e=>{this.locale=s,this.translations=e,t()}).catch(e=>{this.locale="en",n(e)})})},format_locale(e){return e.split("-")[0].toLowerCase()},format_translations_url(e){return"/"!==e.charAt(e.length-1)&&(e+="/"),e},format_supported_languages(e){return e.map(e=>this.format_locale(e))},update_translations(s){return s=this.format_locale(s),new Promise((n,t)=>{fetch(`${this.translations_url}${s}.json`).then(e=>e.json()).then(e=>{this.translations=e,this.locale=s,localStorage.setItem(this.local_storage_key,s);const t=window.location.pathname.substring(1).split("/");e=t[0];this.supported_languages.includes(e)&&(t.splice(0,1,s),e=t.join("/"),window.history.replaceState(null,"","/"+e)),n()}).catch(e=>{t(e)})})},trad:function(t,n={}){return t=this.translations[t]||t,Object.keys(n).forEach(e=>{t=t.replace(`{%${e}%}`,n[e])}),t}}},{}],4:[function(e,t,n){"use strict";t.exports={register_key:"objectToHtmlRender",register(e){e=e||this.register_key;window[e]=this},setRenderCycleRoot(e){this.renderCycleRoot=e},event_name:"objtohtml-render-cycle",setEventName(e){this.event_name=e},objectToHtml(t){if(!t)return document.createElement("span");const n=this.objectToHtml.bind(this),{tag:e,xmlns:s}=t,a=void 0!==s?document.createElementNS(s,e):document.createElement(e),r=["tag","contents","style_rules","state","xmlns"];return Object.keys(t).filter(e=>!r.includes(e)).forEach(e=>{switch(e){case"class":a.classList.add(...t[e].split(" ").filter(e=>""!==e));break;case"on_render":t.id||(a.id=`${btoa(JSON.stringify(t).slice(0,127)).replace(/\=/g,"")}${window.performance.now()}`),"function"!=typeof t.on_render?console.error("The on_render attribute must be a function"):this.attach_on_render_callback(a,t.on_render);break;default:void 0!==s?a.setAttributeNS(null,e,t[e]):a[e]=t[e]}}),t.contents&&"string"==typeof t.contents?a.innerHTML=t.contents:t.contents&&0<t.contents.length&&t.contents.forEach(e=>{switch(typeof e){case"string":a.innerHTML=e;break;case"object":void 0!==s&&(e=Object.assign(e,{xmlns:s})),a.appendChild(n(e))}}),t.style_rules&&Object.keys(t.style_rules).forEach(e=>{a.style[e]=t.style_rules[e]}),a},on_render_callbacks:[],attach_on_render_callback(t,n){var e={callback:e=>{e.detail.outputNode!==t&&!e.detail.outputNode.querySelector(`#${t.id}`)||(n(t),-1===(e=this.on_render_callbacks.indexOf(this.on_render_callbacks.find(e=>e.node===t)))?console.warn("A callback was registered for node with id "+t.id+" but callbacck handler is undefined."):(window.removeEventListener(this.event_name,this.on_render_callbacks[e].callback),this.on_render_callbacks.splice(e,1)))},node:t},e=this.on_render_callbacks.push(e);window.addEventListener(this.event_name,this.on_render_callbacks[e-1].callback)},renderCycle:function(){var e,e=document.getElementsByTagName("main")[0]||(e=document.createElement("main"),document.body.appendChild(e),e);this.subRender(this.renderCycleRoot.render(),e,{mode:"replace"})},subRender(e,t,n={mode:"append"}){let s=null;var a=()=>(s=this.objectToHtml(e),s);switch(n.mode){case"append":t.appendChild(a());break;case"override":t.innerHTML="",t.appendChild(a());break;case"insert-before":t.insertBefore(a(),t.childNodes[n.insertIndex]);break;case"adjacent":t.insertAdjacentHTML(n.insertLocation,a());break;case"replace":t.parentNode.replaceChild(a(),t);break;case"remove":t.remove()}var r=this.event_name,r=new CustomEvent(r,{detail:{inputObject:e,outputNode:s,insertOptions:n,targetNode:t}});window.dispatchEvent(r)}}},{}],5:[function(e,t,n){"use strict";t.exports=class{constructor(e){this.props=e,this.id=this.props.images.join("").replace(/\s\./g),this.state={showImageIndex:0},this.RUN_INTERVAL=5e3,1<this.props.images.length&&this.run()}run(){this.runningInterval=setInterval(()=>{var{showImageIndex:e}=this.state,{images:t}=this.props;this.state.showImageIndex=e<t.length-1?++e:0,this.refreshImage()},this.RUN_INTERVAL)}setImageIndex(e){clearInterval(this.runningInterval),this.state.showImageIndex=e,this.refreshImage()}refreshImage(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}render(){const{showImageIndex:n}=this.state,{images:e}=this.props;return{tag:"div",id:this.id,class:"image-carousel",contents:[{tag:"img",property:"image",alt:`image carousel ${e[n].replace(/\.[A-Za-z]+/,"")}`,src:e[n]},1<e.length&&{tag:"div",class:"carousel-bullets",contents:e.map((e,t)=>{return{tag:"span",class:`bullet ${n===t?"active":""}`,onclick:this.setImageIndex.bind(this,t)}})}]}}}},{}],6:[function(e,t,n){"use strict";const{fetch_json_or_error_text:s}=e("./fetch");t.exports={loadArticles:function(e,t){return s(`/articles/${e}/${t}`)},getArticleBody:function(e){return e.replaceAll("\n","<br/>")},getArticleDate:function(e){return`${e.getDate()}-${e.getMonth()+1}-${e.getFullYear()}`}}},{"./fetch":7}],7:[function(e,t,n){"use strict";t.exports={fetchjson:function(e){return new Promise((t,n)=>{fetch(e).then(e=>e.json()).then(e=>t(e)).catch(e=>n(e))})},fetchtext:function(e){return new Promise((t,n)=>{fetch(e).then(e=>e.text()).then(e=>t(e)).catch(e=>n(e))})},fetch_json_or_error_text:async function(e,s={}){return new Promise((t,n)=>{fetch(e,s).then(async e=>{400<=e.status&&e.status<600?n(await e.text()):t(await e.json())})})}}},{}],8:[function(e,t,n){"use strict";const s=e("ks-cheap-translator"),{translations_url:a}=e("../../constants");t.exports=class{constructor(e){Object.assign(this,e),this.id||(this.id="webpage-"+performance.now()),s.init({translations_url:a,supported_languages:["fr","en"]}).then(this.refresh_all.bind(this))}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}refresh_all(){obj2htm.renderCycle()}}},{"../../constants":2,"ks-cheap-translator":3}],9:[function(e,t,n){"use strict";const{images_url:r}=e("../../../../constants"),o=e("../../../generic-components/image-carousel"),{getArticleBody:i}=e("../../../lib/article-utils");t.exports=class{constructor(e){this.props=e}render(){const{title:e,body:t,subtitle:n,images:s,details:a=[]}=this.props;return{tag:"article",class:"edu-article",typeof:"Article",contents:[{tag:"h2",class:"edu-art-title",contents:e,property:"name"},{tag:"div",class:"edu-art-image",contents:[{tag:"img",src:`${r}/${s[0]}`}]},{tag:"h3",class:"edu-art-subtitle",contents:n,property:"alternativeHeadline"},{tag:"div",class:"edu-art-description",contents:i(t),property:"description"},1<s.length&&{tag:"div",class:"edu-art-carousel",contents:[new o({images:s.map(e=>`${r}/${e}`)}).render()]},0<a.length&&{tag:"div",class:"article-details edu-art-details",contents:[{tag:"h2",contents:"Details"},{tag:"ul",class:"details-list",contents:a.map(e=>({tag:"li",class:"detail",contents:[{tag:"label",contents:e.label},{tag:"div",class:"detail-value",contents:e.value}]}))}]}]}}}},{"../../../../constants":2,"../../../generic-components/image-carousel":5,"../../../lib/article-utils":6}],10:[function(e,t,n){"use strict";const{loadArticles:s}=e("../../../lib/article-utils"),a=e("ks-cheap-translator"),r=a.trad.bind(a),o=e("./edu-article");t.exports=class{constructor(e){this.props=e,this.state={articles:[],loaded:!1},this.id="edu-articles-section",this.loadArticles()}loadArticles(){s("education",a.locale).then(e=>{this.state.articles=e}).catch(e=>console.log(e)).finally(()=>{this.state.loaded=!0,this.refresh()})}renderPlaceholder(){return{tag:"article",class:"placeholder",contents:[{tag:"div"},{tag:"div"},{tag:"div"},{tag:"div"}]}}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}render(){const{articles:e,loaded:t}=this.state;return{tag:"section",class:"edu-articles page-contents-center",id:this.id,contents:t&&0<e.length?e.map(e=>new o({...e}).render()):t&&0===e.length?[{tag:"p",contents:r("Rien de prévu pour le moment")}]:[this.renderPlaceholder()]}}}},{"../../../lib/article-utils":6,"./edu-article":9,"ks-cheap-translator":3}],11:[function(e,t,n){"use strict";const{images_url:s}=e("../../../constants");var a=e("../../lib/web-page");const r=e("ks-cheap-translator"),o=e("./components/edu-articles"),i=r.trad.bind(r),c=[{title:"Programmation",description:"edu-learn-coding",image:"learning_theme_coding.png"},{title:"Dessin numérique et animation 2D",description:"edu-learn-2d",image:"learning_theme_2d.png"},{title:"Maths et physique",description:"edu-learn-math",image:"learning_theme_math.png"},{title:"Aide informatique générale",description:"edu-learn-computer",image:"learning_theme_pc.png"},{title:"Stage GNU/Linux",description:"edu-learn-gnu",image:"learning_theme_linux.png"},{title:"Créer un jeu avec Mentalo",description:"edu-learn-mentalo",image:"learning_theme_mentalo.png"}];class l extends a{render(){return{tag:"div",id:"education-page",typeof:"EducationalOrganization",contents:[{tag:"div",class:"page-header logo-left",contents:[{tag:"div",class:"page-contents-center grid-wrapper",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"image brain",src:`${s}/brain.svg`}]},{tag:"h1",contents:i("Pédagogie")},{tag:"p",contents:i("edu-page-intro")}]}]},{tag:"div",class:"title-banner"},{tag:"section",contents:[{tag:"div",class:"page-contents-center",contents:[{tag:"ul",class:"edu-themes",contents:c.map(e=>({tag:"li",class:"edu-theme",contents:[{tag:"img",width:250,height:140,class:"pixelated",src:`${s}/${e.image}`},{tag:"h3",contents:i(e.title)},{tag:"p",contents:i(e.description)}]}))}]}]},{tag:"h2",class:"edu-section-title page-contents-center",contents:i("Programme XXXX",{date:"2022"})},(new o).render()]}}}t.exports=l},{"../../../constants":2,"../../lib/web-page":8,"./components/edu-articles":10,"ks-cheap-translator":3}],12:[function(e,t,n){"use strict";const s=e("../../run-page");e=e("./education");s(e)},{"../../run-page":13,"./education":11}],13:[function(e,t,n){"use strict";const s=e("object-to-html-renderer"),a=e("./template/template");t.exports=function(e){e=new a({page:new e});s.register("obj2htm"),obj2htm.setRenderCycleRoot(e),obj2htm.renderCycle()}},{"./template/template":15,"object-to-html-renderer":4}],14:[function(e,t,n){"use strict";const{images_url:s}=e("../../../constants"),o=e("ks-cheap-translator"),i=o.trad.bind(o),a=[{url:"/games/",text:"Jeux"},{url:"/education/",text:"Pédagogie"},{url:"/software-development/",text:"Software"}];t.exports=class{constructor(){this.initEventHandlers()}handleBurgerClick(){document.getElementById("nav-menu-list").classList.toggle("responsive-show")}initEventHandlers(){window.addEventListener("click",e=>{"nav-menu-list"===e.target.id||e.target.classList.contains("burger")||e.target.parentNode.classList.contains("burger")||document.getElementById("nav-menu-list").classList.remove("responsive-show")})}handle_chang_lang(e){o.update_translations(e).then(()=>{obj2htm.renderCycle()}).catch(e=>console.log(e))}renderHome(){return{tag:"div",class:"home",contents:[{tag:"a",href:"/",contents:[{tag:"img",alt:"Logo Kuadrado",src:`${s}/logo_kuadrado.svg`},{tag:"img",alt:"Kuadrado Software",class:"logo-text",src:`${s}/logo_kuadrado_txt.svg`}]}]}}renderMenu(e,a=!1,r=""){return{tag:"ul",id:"nav-menu-list",class:a?"submenu":"",contents:e.map(e=>{var{url:t,text:n,submenu:e}=e;const s=`${r}${t}`;return{tag:"li",class:a||window.location.pathname!==s?"":"active",contents:[{tag:"a",href:s,contents:i(n)}].concat(e?[this.renderMenu(e,!0,t)]:[])}}).concat({tag:"li",class:"lang-flags",contents:["fr","en"].map(e=>({tag:"img",src:`${s}/flag-${e}.svg`,class:o.locale===e?"selected":"",onclick:this.handle_chang_lang.bind(this,e)}))})}}renderResponsiveBurger(){return{tag:"div",class:"burger",onclick:this.handleBurgerClick.bind(this),contents:[{tag:"span",contents:"···"}]}}render(){return{tag:"nav",contents:[this.renderHome(),this.renderResponsiveBurger(),this.renderMenu(a)]}}}},{"../../../constants":2,"ks-cheap-translator":3}],15:[function(e,t,n){"use strict";const{in_construction:s}=e("../../config"),{images_url:a}=e("../../constants"),r=e("./components/navbar"),o=e("ks-cheap-translator"),i=o.trad.bind(o);t.exports=class{constructor(e){this.props=e}render(){return{tag:"main",contents:[{tag:"header",contents:[(new r).render()]},s&&{tag:"section",class:"warning-banner",contents:[{tag:"strong",class:"page-contents-center",contents:i("Site en construction ...")}]},{tag:"section",id:"page-container",contents:[this.props.page.render()]},{tag:"footer",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"logo Kuadrado",src:`${a}/logo_kuadrado.svg`},{tag:"img",class:"text-logo",alt:"Kuadrado Software",src:`${a}/logo_kuadrado_txt.svg`}]},{tag:"span",contents:"32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France"},{tag:"div",contents:[{tag:"strong",contents:"<blue>Contact : </blue>"},{tag:"a",href:"mailto:contact@kuadrado-software.fr",contents:"contact@kuadrado-software.fr"}]},{tag:"div",class:"social",contents:[{tag:"strong",contents:`<blue>${i("Sur les réseaux")} : </blue>`},{tag:"a",href:"https://www.linkedin.com/company/kuadrado-software",target:"_blank",contents:"in",title:"Linkedin"},{tag:"a",href:"https://mastodon.gamedev.place/@kuadrado_software",target:"_blank",contents:"m",title:"Mastodon"}]},{tag:"span",contents:`Copyleft 🄯 ${(new Date).getFullYear()} Kuadrado Software | 
+                                ${i("kuadrado-footer-copyleft")}`},{tag:"div",contents:[{tag:"span",contents:i("Ce site web est")+" "},{tag:"a",target:"_blank",style_rules:{fontWeight:"bold"},href:"https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",contents:"OPEN SOURCE"}]}]}]}}}},{"../../config":1,"../../constants":2,"./components/navbar":14,"ks-cheap-translator":3}]},{},[12]);
\ No newline at end of file
diff --git a/public/games/games.js b/public/games/games.js
index 45e5397167b4728f7a5098298dd06a86c175224f..d497e273510df8e18cd000ce74e5620e72c3f2b3 100644
--- a/public/games/games.js
+++ b/public/games/games.js
@@ -1,4503 +1,2 @@
-(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
-module.exports = {
-    images_url: "/assets/images"
-}
-},{}],2:[function(require,module,exports){
-module.exports = {
-    build: {
-        protected_dirs: ["assets", "style", "views", "standard"],
-        default_meta_keys: ["title", "description", "image", "open_graph", "json_ld"],
-    },
-};
-
-},{}],3:[function(require,module,exports){
-module.exports = {
-    images_url: `/assets/images`,
-    data_url: `/assets/data`,
-    translations_url: "/assets/translations"
-};
-
-},{}],4:[function(require,module,exports){
-"use strict";
-/**
- * A tiny library to handle text translation.
- */
-
-/**
- * init parameter object type
- * @typedef TranslatorParam
- * @property {String[]} supported_languages - DEFAULT: ["en"] - An array of ISO 639 lowercase language codes
- * @property {String} locale ISO 639 lowercase language code - DEFAULT: "en"
- *
- * @property {Boolean} use_url_locale_fragment - DEFAULT: true -
- * If true, the 1st fragment of the window.location url will be parsed as a language code
- * Example:
- * window.location
- * > https://example.com/en/some-page...
- * then the /en/ part of the url will be taken in priority and locale will be set to "en".
- * window.location
- * > https://example.com/some-page...
- * No locale fragment will be found so locale will not be set from url fragment.
- * window.location
- * > https://example.com/some-page/en
- * Doesn't work, locale fragment must be the first framgment
- *
- * @property {String} local_storage_key  - DEFAULT: "translator-prefered-language"
- *  The key used to saved the current locale into local storage
- *
- * @property {String} translations_url - REQUIRED - the url of the directory containing the static json files for translations
- * Translations files are expected to be named with their corresponding locale code.
- * Example:
- * if supported_languages is set to ["en", "fr", "it"]
- * and translations_url is set to "https://example.com/translations/""
- * Then the expected translations files are
- * https://example.com/translations/en.json
- * https://example.com/translations/fr.json
- * https://example.com/translations/it.json
- * The json resources must simple key value maps, value being the translated text.
- */
-
-module.exports = {
-    locale: "en", // ISO 639 lowercase language code
-    supported_languages: ["en"],
-    translations: {},
-    translations_url: "",
-    use_url_locale_fragment: true,
-    local_storage_key: "translator-prefered-language",
-
-    /**
-     * Initialize the lib with params
-     * @param {TranslatorParam} params 
-     * @returns {Promise}
-     */
-    init(params) {
-        Object.entries(params).forEach(k_v => {
-            const [key, value] = k_v;
-            if ([
-                "supported_languages",
-                "use_url_locale_fragment",
-                "local_storage_key",
-                "translations_url"
-            ].includes(key)) {
-                this[key] = value;
-            }
-        });
-
-        this.translations_url = this.format_translations_url(this.translations_url);
-        this.supported_languages = this.format_supported_languages(this.supported_languages);
-
-        return new Promise((resolve, reject) => {
-            const loc =
-                (() => {// Locale from url priority 1
-                    if (this.use_url_locale_fragment) {
-                        const first_url_fragment = window.location.pathname.substring(1).split("/")[0];
-                        const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-                        return fragment_is_locale ? first_url_fragment : "";
-                    } else {
-                        return "";
-                    }
-                })()
-                || localStorage.getItem(this.local_storage_key) // Locale from storage priority 2
-                || (() => { // locale from navigator priority 3
-                    const navigator_locale = navigator.language.split("-")[0].toLocaleLowerCase();
-                    return this.supported_languages.includes(navigator_locale)
-                        ? navigator_locale
-                        : this.supported_languages[0] // Default if navigator locale is not supported
-                })();
-
-            fetch(`${this.translations_url}${loc}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.locale = loc;
-                    this.translations = response;
-                    resolve();
-                })
-                .catch(err => {
-                    this.locale = "en";
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Return a lowercase string without dash.
-     * If given locale is en-EN, then "en" will be returned.
-     * @param {String} locale 
-     * @returns A lowercase string
-     */
-    format_locale(locale) {
-        return locale.split("-")[0].toLowerCase();
-    },
-
-    /**
-     * Appends a slash at the end of the given string if missing, and returns the url.
-     * @param {String} url 
-     * @returns {String}
-     */
-    format_translations_url(url) {
-        if (url.charAt(url.length - 1) !== "/") {
-            url += "/"
-        }
-        return url;
-    },
-
-    /**
-     * Return the array of language codes formatted as lowsercase language code.
-     * if ["en-EN", "it-IT"]is given, ["en", "it"] will b returned.
-     * @param {String[]} languages_codes 
-     * @returns {String[]}
-     */
-    format_supported_languages(languages_codes) {
-        return languages_codes.map(lc => this.format_locale(lc));
-    },
-
-    /**
-     * Fetches a new set of translations in case the wanted language changes
-     * @param {String} locale A lowercase language code
-     * @returns {Promise}
-     */
-    update_translations(locale) {
-        locale = this.format_locale(locale);
-        return new Promise((resolve, reject) => {
-            fetch(`${this.translations_url}${locale}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.translations = response;
-                    this.locale = locale;
-
-                    localStorage.setItem(this.local_storage_key, locale);
-
-                    const split_path = window.location.pathname.substring(1).split("/");
-                    const first_url_fragment = split_path[0];
-                    const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-
-                    if (fragment_is_locale) {
-                        split_path.splice(0, 1, locale);
-                        const updated_path = split_path.join("/");
-                        window.history.replaceState(null, "", "/" + updated_path);
-                    }
-                    resolve();
-                })
-                .catch(err => {
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Tries to get the translation of the source string, or return the string as it.
-     * @param {String} text The source text to translate
-     * @param {Object} params Some dynamic element to insert in the text
-     * Params can be used if the translated text provide placeholder like {%some_word%}
-     * Example:
-     * translator.trad("Some trad key", {some_word: "a dynamic parameter"})
-     * -> translation for "Some trad key": "A translated text with {%some_word%}"
-     * -> will be return as : "A translated text with a dynamic parameter"
-     * @returns {String}
-     */
-    trad: function (text, params = {}) {
-        text = this.translations[text] || text;
-
-        Object.keys(params).forEach(k => {
-            text = text.replace(`{%${k}%}`, params[k]);
-        });
-
-        return text;
-    }
-};
-},{}],5:[function(require,module,exports){
-"use strict";
-
-const MtlAnimation = require("./model/animation");
-const MentaloEngine = require("./mentalo-engine");
-const MtlScene = require("./model/scene");
-const MtlGame = require("./model/game");
-const MtlSoundTrack = require("./model/sound-track");
-const SCENE_TYPES = require("./model/scene-types");
-const MtlChoice = require("./model/choice");
-const MtlGameObject = require("./model/game-object");
-const FrameRateController = require("./lib/frame-rate-controller");
-const font_tools = require("./lib/font-tools");
-const color_tools = require("./lib/color-tools");
-const shape_tools = require("./lib/shape-tools");
-module.exports = {
-    MentaloEngine,
-    MtlAnimation,
-    MtlScene,
-    MtlGame,
-    MtlSoundTrack,
-    SCENE_TYPES,
-    MtlChoice,
-    MtlGameObject,
-    FrameRateController,
-    font_tools,
-    color_tools,
-    shape_tools,
-};
-},{"./lib/color-tools":6,"./lib/font-tools":7,"./lib/frame-rate-controller":8,"./lib/shape-tools":9,"./mentalo-engine":11,"./model/animation":12,"./model/choice":13,"./model/game":15,"./model/game-object":14,"./model/scene":19,"./model/scene-types":18,"./model/sound-track":20}],6:[function(require,module,exports){
-"use strict";
-/**
- * Helpers to work with color values
- */
-
-/**
- * Get a RGB color value in String from either "rgb(x,x,x)" or #xxxxxx
- * @param {String} color A RGB color value as String in the form: #ffffff or rgb(x,x,x)
- * @returns {Uint8[]} An array of 3 values from 0 to 256 for [RED, GREEN, BLUE]
- */
-function color_str_to_rgb_array(color) {
-    color = color.toLowerCase(0);
-    if (color.includes("rgb")) {
-        return color.replaceAll(/[a-z\(\)w]/g, "")
-            .split(",")
-            .slice(0, 3)
-            .map(n => parseInt(n));
-    } else {
-        return color.replace("#", "")
-            .match(/.{0,2}/g)
-            .slice(0, 3)
-            .map(hex => parseInt(hex, 16));
-    }
-}
-
-/**
- * @param {String} color A color value (#xxxxxx or rbg(x,x,x))
- * @returns The average between each tone of the given color.
- */
-function get_average_rgb_color_tone(color) {
-    return color_str_to_rgb_array(color).reduce((tot, n) => tot + n / 3, 0);
-}
-
-/**
- * Gets an array of 3 8bits integers for red green and blue and and return a color value as an hexadecimal string #xxxxxx
- * @param {Uint8[]} rgb 
- * @returns {String} The hexacdecimal rgb value of the color including the hash character: #xxxxxx
- */
-function rgb_array_to_hex(rgb) {
-    return `#${rgb.slice(0, 3).map(n => {
-        const hex = n.toString(16);
-        return hex.length < 2 ? '0' + hex : hex;
-    }).join('')}`;
-}
-
-/**
- * Gets an array of 4 8bits integers for red green blue and transparency channel and return a color value as an hexadecimal string #xxxxxx
- * @param {Uint8[]} rgb
- * @returns {String} The hexacdecimal rgba value of the color including the hash character: #xxxxxxxx
- */
-function rgba_array_to_hex(rgba) {
-    rgba = rgba.length === 4 ? rgba : rgba.concat([255])
-    return `#${rgba.slice(0, 4).map(n => {
-        const hex = n.toString(16);
-        return hex.length < 2 ? '0' + hex : hex;
-    }).join('')}`;
-}
-
-/**
- * Gets a background color as argument and return a calculated optimal foreground color.
- * For example if the background is dark the function will return a lighten version of the background.
- * @param {String} base_color A RGB color value in hexadecimal form: #ffffff
- * @returns 
- */
-function get_optimal_visible_foreground_color(base_color) {
-    // create a 2D 1px canvas context
-    const tmp_canvas = document.createElement("canvas");
-    tmp_canvas.width = 1;
-    tmp_canvas.height = 1;
-    const ctx = tmp_canvas.getContext("2d");
-
-    // fill it with the given color
-    ctx.fillStyle = base_color;
-    ctx.fillRect(0, 0, 1, 1);
-
-    // Get either a semi-transparent black or semi-transparent white regarding the base color is above or below an avg of 127
-    const superpo_color = get_average_rgb_color_tone(base_color) > 127 ? "#0000003f" : "#ffffff3f";
-
-    // Fill the pixel with the semi-transparent black or white on top of the base color
-    ctx.fillStyle = superpo_color;
-    ctx.fillRect(0, 0, 1, 1);
-
-    // Return the flatten result of the superposition
-    return rgb_array_to_hex(Array.from(ctx.getImageData(0, 0, 1, 1).data));
-}
-
-/**
- * @param {Uint8[]} col1 A RGBA color value as an array of 4 0 to 256 integers
- * @param {Uint8[]} col2 A RGBA color value as an array of 4 0 to 256 integers
- * @returns {Boolean} true if the 2 colors are equals
- */
-function same_rgba(col1, col2) {
-    return col1[0] === col2[0]
-        && col1[1] === col2[1]
-        && col1[2] === col2[2]
-        && col1[3] === col2[3];
-}
-
-module.exports = {
-    get_average_rgb_color_tone,
-    get_optimal_visible_foreground_color,
-    color_str_to_rgb_array,
-    rgb_array_to_hex,
-    rgba_array_to_hex,
-    same_rgba,
-};
-},{}],7:[function(require,module,exports){
-"use strict";
-/**
- * Helpers to works with default web fonts
- */
-
-
-const FONT_FAMILIES = [
-    {
-        category: "Sans serif", values: [
-            { value: "sans-serif", text: "sans-serif" },
-            { text: "Arial", value: "Arial, sans-serif" },
-            { text: "Helvetica", value: "Helvetica, sans-serif" },
-            { text: "Verdana", value: "Verdana, sans-serif" },
-            { text: "Trebuchet MS", value: "Trebuchet MS, sans-serif" },
-            { text: "Noto Sans", value: "Noto Sans, sans-serif" },
-            { text: "Gill Sans", value: "Gill Sans, sans-serif" },
-            { text: "Avantgarde", value: "Avantgarde, TeX Gyre Adventor, URW Gothic L, sans-serif" },
-            { text: "Optima", value: "Optima, sans-serif" },
-            { text: "Arial Narrow", value: "Arial Narrow, sans-serif" }
-        ],
-    },
-    {
-        category: "Serif", values: [
-            { text: "serif", value: "serif" },
-            { text: "Times", value: "Times, Times New Roman, serif" },
-            { text: "Didot", value: "Didot, serif" },
-            { text: "Georgia", value: "Georgia, serif" },
-            { text: "Palatino", value: "Palatino, URW Palladio L, serif" },
-            { text: "Bookman", value: "Bookman, URW Bookman L, serif" },
-            { text: "New Century Schoolbook", value: "New Century Schoolbook, TeX Gyre Schola, serif" },
-            { text: "American Typewriter", value: "American Typewriter, serif" }
-        ],
-    },
-    {
-        category: "Monospace", values: [
-            { text: "monospace", value: "monospace" },
-            { text: "Andale Mono", value: "Andale Mono, monospace" },
-            { text: "Courrier New", value: "Courier New, monospace" },
-            { text: "Courrier", value: "Courier, monospace" },
-            { text: "FreeMono", value: "FreeMono, monospace" },
-            { text: "OCR A Std", value: "OCR A Std, monospace" },
-            { text: "DejaVu Sans Mono", value: "DejaVu Sans Mono, monospace" },
-        ],
-    },
-    {
-        category: "Cursive", values: [
-            { text: "cursive", value: "cursive" },
-            { text: "Comic Sans MS", value: "Comic Sans MS, Comic Sans, cursive" },
-            { text: "Apple Chancery", value: "Apple Chancery, cursive" },
-            { text: "Bradley Hand", value: "Bradley Hand, cursive" },
-            { text: "Brush Script MT", value: "Brush Script MT, Brush Script Std, cursive" },
-            { text: "Snell Roundhand", value: "Snell Roundhand, cursive" },
-            { text: "URW Chancery L", value: "URW Chancery L, cursive" },
-        ],
-    }
-];
-
-const TEXT_ALIGN_OPTIONS = [
-    { value: "left", text: "Left" }, // TODO trad or icon
-    { value: "right", text: "Right" },
-    { value: "center", text: "Center" },
-];
-
-const FONT_STYLE_OPTIONS = [
-    { value: "normal", text: "Normal" },
-    { value: "italic", text: "Italic" }, // TODO trad or icon
-];
-
-const FONT_WEIGHT_OPTIONS = [
-    { value: "1", text: "Thin" },
-    { value: "normal", text: "Normal" },
-    { value: "900", text: "Bold" },
-];
-
-/**
- * Gets an object description of a font and returns it formatted as a string that can be given to a 2D drawing context.
- * Example : ctx.font = get_canvas_font({font_family:"monospace", font_size: 32})
- * @param {Object} font_data An object description of the font settings, with font_size, font_family, font_style, font_weight.s
- * @returns {String}
- */
-function get_canvas_font(font_data = {}) {
-    const { font_size = 20, font_family = "sans-serif", font_style = "normal", font_weight = "normal" } = font_data;
-    const font_variant = "normal";
-    return `${font_style} ${font_variant} ${font_weight} ${font_size.toFixed()}px ${font_family}`;
-}
-
-/**
- * @returns {Object} A map of all defined constants for font families, text align options, font styles and font weights.
- */
-function get_font_options() {
-    return {
-        font_families: FONT_FAMILIES,
-        text_align_options: TEXT_ALIGN_OPTIONS,
-        font_style_options: FONT_STYLE_OPTIONS,
-        font_weight_options: FONT_WEIGHT_OPTIONS,
-    };
-}
-
-/**
- * An approximation of the average width and height of a character for a 2D drawing context with a given font configuration.
- * @param {CanvasRenderingContext2D} ctx
- * @returns {Object} An object with {width, height, text_line_height} entries
- */
-function get_canvas_char_size(ctx, font_settings) {
-    ctx.save();
-    ctx.font = get_canvas_font(font_settings);
-
-    const str = `Lorem ipsum dolor Sit amet, Consectetur adipiscing elit`;
-    const width = ctx.measureText(str).width / str.length;
-
-    // Scale width by an approximative 1.1 factor to calculate the height, 
-    // for example for letters like j q p etc that have a part bellow the text base line
-    const height = ctx.measureText("M").width * 1.1;
-    const text_line_height = height + height / 4;
-
-    ctx.restore();
-
-    return {
-        width,
-        height,
-        text_line_height,
-        interline_height: height / 4
-    }
-}
-
-module.exports = {
-    get_canvas_font,
-    get_font_options,
-    get_canvas_char_size,
-    FONT_FAMILIES,
-    TEXT_ALIGN_OPTIONS,
-    FONT_STYLE_OPTIONS,
-    FONT_WEIGHT_OPTIONS
-};
-},{}],8:[function(require,module,exports){
-"use strict";
-
-/**
- * A little helper class to control the rendering frame rate
- */
-class FrameRateController {
-    /**
-     * @param {Integer} fps The wanted frame rate in frame per second
-     */
-    constructor(fps) {
-        this.tframe = performance.now();
-        this.interval = 1000 / fps; // convert in milliseconds
-        this.initial = true;
-    }
-
-    /**
-     * @returns {Boolean} true if the defined interval is elapsed.
-     */
-    nextFrameReady() {
-        if (this.initial) {
-            this.initial = false;
-            return true;
-        }
-
-        const now = performance.now();
-        const elapsed = now - this.tframe;
-        const ready = elapsed > this.interval;
-
-        if (ready) {
-            this.tframe = now - (elapsed % this.interval);
-        }
-
-        return ready;
-    }
-}
-
-module.exports = FrameRateController;
-},{}],9:[function(require,module,exports){
-"use strict";
-
-/**
- * Draws a rectangle on a canvas 2D context with support of corner rounding and border options.
- * @param {CanvasRenderingContext2D} ctx
- * @param {Integer} x 
- * @param {Integer} y 
- * @param {Integer} width 
- * @param {Integer} height 
- * @param {Object} options 
- */
-function draw_rect(ctx, x, y, width, height, options = {
-    rounded_corners_radius: 0,
-    border: { width: 0, color: "rgba(0,0,0,0)" },
-    fill_color: "black",
-}) {
-    const { rounded_corners_radius = 0, border = { width: 0, color: "rgba(0,0,0,0)" }, fill_color = "black", fill_image } = options;
-    const smallest_axis = Math.min(width, height);
-    const radius = rounded_corners_radius > smallest_axis / 2 ? smallest_axis / 2 : rounded_corners_radius;
-
-    ctx.save();
-
-    ctx.beginPath();
-    ctx.arc(x + radius, y + radius, radius, Math.PI, 3 * Math.PI / 2);
-    ctx.lineTo(x + width - radius, y);
-    ctx.arc(x + width - radius, y + radius, radius, 3 * Math.PI / 2, 0);
-    ctx.lineTo(x + width, y + height - radius);
-    ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI / 2);
-    ctx.lineTo(x + radius, y + height);
-    ctx.arc(x + radius, y + height - radius, radius, Math.PI / 2, Math.PI);
-    ctx.closePath();
-
-    ctx.clip();
-
-    if (fill_image) {
-        ctx.drawImage(
-            fill_image.src,
-            0, 0,
-            fill_image.src.width, fill_image.src.height,
-            fill_image.dx, fill_image.dy,
-            fill_image.dw, fill_image.dh,
-        )
-    } else {
-        ctx.fillStyle = fill_color;
-        ctx.fillRect(x, y, width, height);
-    }
-
-    if (border.width > 0) {
-        ctx.strokeStyle = border.color;
-        ctx.lineWidth = border.width;
-        ctx.stroke();
-    }
-
-    ctx.restore();
-}
-
-module.exports = {
-    draw_rect,
-}
-},{}],10:[function(require,module,exports){
-"use strict";
-
-const { get_canvas_font, get_canvas_char_size } = require("./font-tools");
-
-/**
- * A utility function to display text inside of a box coordinates
- * @param {CanvasRenderingContext2D} ctx
- * @param {String[]} lines 
- * @param {Object} bounds A bounding box object like {left, right, top, bottom, width, height}
- * @param {Object} settings An object with styling settings like {padding, font_color, background_color, ...}
- * @param {Object} a_state_ref Any object having a persistent life in the calling instance
- * @param {Boolean} streamed Wether the text should be displayed as a progressive text streaming or all at once
- * @returns {Boolean} true if the text is fully displayed
- */
-function draw_text_in_bounds(ctx, lines, bounds, settings, a_state_ref, streamed) {
-    ctx.save();
-    const char_size = get_canvas_char_size(ctx, settings);
-    const line_height = char_size.text_line_height;
-    ctx.font = get_canvas_font(settings);
-    ctx.fillStyle = settings.font_color;
-    ctx.textAlign = settings.text_align;
-    ctx.textBaseline = "top";
-
-    const get_text_position = () => {
-        const { padding = 0 } = settings;
-        const { left, top, width } = bounds;
-        switch (ctx.textAlign) {
-            case "left":
-                return {
-                    x: left + padding,
-                    y: top + padding,
-                }
-            case "right":
-                return {
-                    x: left + width - padding,
-                    y: top + padding,
-                }
-            case "center":
-                return {
-                    x: left + width / 2,
-                    y: top + padding,
-                }
-            default:
-                return {
-                    x: left + padding,
-                    y: top + padding,
-                }
-        }
-    };
-
-    const text_pos = get_text_position();
-
-    a_state_ref.stream = a_state_ref.stream || {
-        line_chars: 0,
-        line_index: 0,
-        complete: false,
-    };
-
-    const output_lines = streamed ? lines.map((line, i) => {
-        const stream_state = a_state_ref.stream;
-        if (i < stream_state.line_index || stream_state.complete) {
-            return line;
-        } else if (i > stream_state.line_index) {
-            return "";
-        } else {
-            const slice = line.slice(0, ++stream_state.line_chars);
-            if (slice.length === line.length) {
-                stream_state.complete = lines.length - 1 === stream_state.line_index;
-                stream_state.line_index = stream_state.complete
-                    ? stream_state.line_index
-                    : stream_state.line_index + 1;
-                stream_state.line_chars = 0;
-            }
-            return slice;
-        }
-    }) : (() => {
-        a_state_ref.stream.complete = true;
-        return lines;
-    })();
-
-    output_lines.forEach(line => {
-        ctx.fillText(line, text_pos.x, text_pos.y);
-        text_pos.y += line_height;
-    });
-
-    ctx.restore();
-
-    return a_state_ref.stream.complete;
-}
-
-module.exports = {
-    draw_text_in_bounds,
-}
-},{"./font-tools":7}],11:[function(require,module,exports){
-"use strict";
-
-const MtlGame = require("./model/game");
-const SCENE_TYPES = require("./model/scene-types");
-const MtlRender = require("./render/render");
-const { supported_locales, get_translated } = require("./translation");
-
-/**
- * The encapsulating class of all the components of the Mentalo game engine.
- * This is the class that should be used from outside to run a game.
- */
-class MentaloEngine {
-    /**
-     * Initialize A MtlGame instance, populates it with the gama_data given as param.
-     * Initializes the DOM element to contain the rendering of the engine.
-     * Defines the listeners for Escape key and initialize a MtlRender instance.
-     * @param {Object} params 
-     * Parameter params.game_data is required and should be the result of the exportation format of the Mentalo editor app.
-     */
-    constructor(params) {
-        this.game = new MtlGame();
-        this.game.load_data(params.game_data);
-
-        this.game.on_load_resources(() => this.loading = false);
-
-        this.use_locale = params.use_locale && supported_locales.includes(params.use_locale)
-            ? params.use_locale
-            : "en";
-
-        /**
-         * Allow to exit game by typing Escape or q
-         * @param {Event} e 
-         */
-        this.escape_listener = e => {
-            const k = e.key.toLowerCase();
-            (k === "escape" || k === "q") && this.quit();
-        };
-
-        window.addEventListener("keydown", this.escape_listener);
-
-        this.on_quit_game = function () {
-            if (this.used_default_container) {
-                this.container.remove();
-            }
-            window.removeEventListener("keydown", this.escape_listener);
-            params.on_quit_game && params.on_quit_game();
-        };
-
-        this.used_default_container = false;
-
-        this.container = params.container || (() => {
-            const el = document.createElement("div");
-            el.style.display = "flex";
-            el.style.justifyContent = "center";
-            el.style.alignItems = "center";
-            el.style.position = "absolute";
-            el.style.top = 0;
-            el.style.bottom = 0;
-            el.style.left = 0;
-            el.style.right = 0;
-            el.style.zIndex = 1000;
-            el.style.overflow = "hidden";
-            document.body.appendChild(el);
-            this.used_default_container = true
-            return el;
-        })();
-
-        this.render = new MtlRender({
-            container: this.container,
-            fullscreen: params.fullscreen,
-            frame_rate: params.frame_rate,
-            get_game_settings: this.get_game_settings.bind(this),
-            get_game_scenes: this.get_game_scenes.bind(this),
-            get_scene: this.get_scene.bind(this),
-            get_scene_index: this.get_scene_index.bind(this),
-            get_inventory: this.get_inventory.bind(this),
-            on_game_object_click: this.on_game_object_click.bind(this),
-            on_drop_inventory_object: this.on_drop_inventory_object.bind(this),
-            on_choice_click: this.on_choice_click.bind(this),
-        });
-
-        this.loading = true;
-        this.will_quit = false;
-    }
-
-    /**
-     * Returns a fragment of the game instance ui_options, which are the styling settings of the game interface.
-     * Can be the settings for text_boxes, choices_panel, inventory, etc.
-     * @param {String} key The key of the wanted interface settings.
-     * @returns {Object} The fragment of this.game.game_ui_options for the given key.
-     */
-    get_game_settings(key) {
-        return this.game.game_ui_options[key];
-    }
-
-    /**
-     * @returns {MtlScene[]} Returns an array of the scenes of the game.
-     */
-    get_game_scenes() {
-        return this.game.scenes;
-    }
-
-    /**
-     * @returns {MtlScene} The scene that is currently displayed
-     */
-    get_scene() {
-        return this.game.get_scene();
-    }
-
-    /**
-     * @returns {Integer} The index of the scene that is currently displayed
-     */
-    get_scene_index() {
-        return this.game.scenes.indexOf(this.get_scene());
-    }
-
-    /**
-     * Returns the game objects currently saved in the inventory.
-     * @returns {MtlGameObject[]}
-     */
-    get_inventory() {
-        return this.game.state.inventory;
-    }
-
-    /**
-     * Sets the flag this.will_quit to true.
-     * This flag will be parsed in the rendering loop and the engine will then effectively exit the game calling __exit().
-     */
-    quit() {
-        this.will_quit = true;
-    }
-
-    /**
-     * Clean the render instance, stops the rendering loop and calls the on_quit_game callback.
-     * This is called when the rendering loop reads the this.will_quit flag as true.
-     */
-    __quit() {
-        window.cancelAnimationFrame(window.mentalo_engine_animation_id);
-        this.render.exit_fullscreen();
-        this.render.clear_event_listeners();
-        this.game.get_soundtrack().stop();
-        this.on_quit_game();
-    }
-
-
-    /**
-     * Initialize the rendering loop and the MtlRender instance.
-     * Also checks that the first loaded scene has an image to load. 
-     * If it doesn't the game will exit immediately in order to avoid having a black screen.
-     * ( If the first scene doesn't have an image to load, the game.on_load_resource callback will 
-     * never be called and the engine will remain in loading state.  )
-     */
-    init() {
-        window.requestAnimationFrame = window.requestAnimationFrame
-            || window.mozRequestAnimationFrame
-            || window.webkitRequestAnimationFrame
-            || window.msRequestAnimationFrame;
-
-        window.cancelAnimationFrame = window.cancelAnimationFrame
-            || window.mozCancelAnimationFrame;
-
-        if (window.mentalo_engine_animation_id) {
-            window.cancelAnimationFrame(window.mentalo_engine_animation_id);
-        }
-
-        this.render.init();
-
-        if (this.game.scenes.find(scene => scene.animation.empty)) {
-            alert(get_translated("Some scenes have an empty image, game cannot be loaded", this.use_locale));
-            this.quit();
-        }
-    }
-
-    /**
-     * Clear the event listeners and rendering element related to the current scene from the MtlRender instance,
-     * And sets the game current scene state to a new index.
-     * @param {Integer} index 
-     */
-    go_to_scene(index) {
-        this.render.reset_user_error_popup();
-        this.render.clear_event_listeners();
-        this.game.get_soundtrack().stop();
-        this.game.go_to_scene(index);
-        this.render.clear_children();
-        this.render.reset_text_box_visibility();
-    }
-
-    /**
-     * Event listener for doubleclick on a game object.
-     * Adds the object to inventory.
-     * @param {MtlGameObject} obj 
-     */
-    on_game_object_click(obj) {
-        this.game.inventory_has_empty_slot() && this.game.add_object_to_inventory(obj);
-    }
-
-    /**
-     * All callback called from the inventory render component when an object image is clicked in the inventory.
-     * Removes the object from inventory.
-     * @param {MtlGameObject} obj 
-     */
-    on_drop_inventory_object(obj) {
-        this.game.remove_object_from_inventory(obj);
-    }
-
-    /**
-     * Callback called from the choices_panel render component.
-     * Handles the click on a choice.
-     * @param {MtlChoice} choice 
-     * @returns 
-     */
-    on_choice_click(choice) {
-        const { destination_scene_index, use_objects } = choice;
-        if (destination_scene_index === -1) { // -1 is default index when the choice destination scene is not set.
-            this.render.set_user_error_popup({
-                text: get_translated("Destination scene has not been set.", this.use_locale),
-            });
-            return;
-        }
-
-        if (destination_scene_index === -2) { // -2 is the index used to indicate that destination scene is just the end of program.
-            this.quit()
-            return;
-        }
-
-        // Handle the case where the clicked choice requires some objects to be presents in the inventory.
-        if (use_objects.value) {
-            const inventory = this.get_inventory();
-            const objs = [];
-            for (const it of use_objects.items) {
-                const ob = Array.from(inventory).find(o => o.name === it.name);
-                if (ob) {
-                    objs.push(ob);
-                } else {
-                    this.render.set_user_error_popup({
-                        text: use_objects.missing_object_message, stream_text: true,
-                    });
-                    return;
-                }
-            }
-
-            objs.forEach(o => use_objects.items
-                .find(it => o.name === it.name)
-                .consume_object && this.game.consume_game_object(o));
-        }
-
-        this.go_to_scene(destination_scene_index);
-    }
-
-    /**
-     * Callback called when a scene of type "Cinematic" reaches end.
-     */
-    on_cinematic_end() {
-        const scene = this.game.get_scene();
-        if (scene.end_cinematic_options.quit) {
-            this.quit();
-        } else if (scene.end_cinematic_options.destination_scene_index === -1) {
-            this.render.set_user_error_popup({
-                text: get_translated("Next scene has not been set.", this.use_locale),
-                on_close: this.quit.bind(this),
-            });
-        } else {
-            this.go_to_scene(scene.end_cinematic_options.destination_scene_index);
-        }
-    }
-
-    /**
-     * The rendering loop.
-     * Handles the different states of the engine (quit, loading or runnning normally)
-     * @returns The id of the registered window.requestAnimationFrame
-     */
-    run_game() {
-        if (this.will_quit) {
-            this.__quit();
-            return;
-        } else if (this.loading) {
-            this.render.draw_loading();
-        } else {
-            if (this.get_scene()._type === SCENE_TYPES.CINEMATIC) {
-                this.game.update_cinematic_timeout();
-                if (this.game.is_cinematic_ended()) {
-                    this.on_cinematic_end();
-                }
-            }
-
-            const sound_track = this.game.get_soundtrack();
-            if (sound_track.loaded && !sound_track.is_playing()) {
-                sound_track.play();
-            }
-
-            this.render.draw_game();
-        }
-
-        window.mentalo_engine_animation_id = requestAnimationFrame(this.run_game.bind(this));
-    }
-}
-
-module.exports = MentaloEngine;
-},{"./model/game":15,"./model/scene-types":18,"./render/render":23,"./translation":35}],12:[function(require,module,exports){
-"use strict";
-const Loadable = require("./loadable");
-
-/**
- * The data type used for a MtlScene animation
- */
-class MtlAnimation extends Loadable {
-    /**
-     * Initialize an empty instance of Image HtmlElement,
-     * and registers the onload callback to update the instance with the loaded image actual dimensions.
-     */
-    constructor() {
-        super(new Image(), "image", "load");
-        this.image.onload = () => this.update_dimensions();
-
-        this.name = "";
-        this.dimensions = {
-            width: 0,
-            height: 0,
-        };
-        this.frame_nb = 1;
-        this.frame = 0;
-        this.speed = 1;
-        this.play_once = false;
-        this.initialized = false;
-        this.finished = false;
-    }
-
-    /**
-     * Copies the dimensions of the loaded image at the root level of the instance.
-     * Deletes the canvas dimensions precalculations created by the SceneAnimation component
-     */
-    update_dimensions() {
-        this.dimensions = {
-            width: this.image.width / this.frame_nb,
-            height: this.image.height,
-        };
-
-        if (this.canvas_precalc) {
-            delete this.canvas_precalc;
-        }
-    }
-
-    /**
-     * Populates the instance with loaded litteral data.
-     * @param {Object} data 
-     */
-    init(data) {
-        this.empty = data.src === "";
-        this.name = data.name;
-        this.image.src = data.src;
-        this.frame_nb = data.frame_nb;
-        this.frame = 0;
-        this.speed = data.speed;
-        this.play_once = data.play_once;
-        this.initialized = true;
-    }
-
-    /**
-     * Increments the framcount argument if the animation next frame is ready to be rendered.
-     * @param {Integer} framecount 
-     */
-    update_frame(framecount) {
-        this.finished = this.play_once && this.frame === this.frame_nb - 1;
-        if (this.frame_nb > 1 && framecount % this.speed === 0 && !this.finished) {
-            this.frame = this.frame + 1 <= this.frame_nb - 1 ? this.frame + 1 : 0;
-        }
-    }
-
-    /**
-     * Reset the frame state.
-     */
-    reset_frame() {
-        this.finished = false;
-        this.frame = 0;
-    }
-}
-
-module.exports = MtlAnimation;
-},{"./loadable":17}],13:[function(require,module,exports){
-"use strict";
-/**
- * The data type used for the choices of a MtlScene
- */
-class MtlChoice {
-    /**
-     * Initializes the instance with given data or default empty data.
-     * @param {Object} data 
-     */
-    constructor(data = {
-        text: "",
-        destination_scene_index: -1,
-        use_objects: {
-            value: false,
-            items: [],
-            missing_object_message: "",
-        }
-    }) {
-        this.load_data(data);
-    }
-
-    /**
-     * Populates the instance with litteral description data
-     * @param {Object} data 
-     */
-    load_data(data) {
-        this.text = data.text;
-        this.destination_scene_index = data.destination_scene_index;
-        this.use_objects = data.use_objects;
-    }
-}
-
-module.exports = MtlChoice;
-},{}],14:[function(require,module,exports){
-"use strict";
-const Loadable = require("./loadable");
-
-/**
- * The data type used for the game_objects field of a MtlScene
- */
-class MtlGameObject extends Loadable {
-    /**
-     * Initializes the instance with an empty image and zero position.
-     */
-    constructor() {
-        super(new Image(), "image", "load");
-        this.name = "";
-        this.position = { x: 0, y: 0 };
-        this.state = {};
-    }
-
-    /**
-     * Populates the instance with a litteral description data object
-     * @param {Object} data 
-     */
-    load_data(data) {
-        this.image.src = data.image;
-        this.name = data.name;
-        this.position = data.position;
-    }
-
-    /**
-     * @returns {Object} A {width, height} object for the dimensions of the image.
-     */
-    get_dimensions() {
-        return {
-            width: this.image.width,
-            height: this.image.height,
-        }
-    }
-
-    /**
-     * Returns the bounding box coordinates of the object image as a {top, right, bottom, left} object.
-     * @returns {Object} 
-     */
-    get_bounds() {
-        const dims = this.get_dimensions();
-        return {
-            top: this.position.y,
-            right: this.position.x + dims.width,
-            bottom: this.position.y + dims.height,
-            left: this.position.x,
-        }
-    }
-}
-
-module.exports = MtlGameObject;
-},{"./loadable":17}],15:[function(require,module,exports){
-"use strict";
-
-const MtlScene = require("./scene");
-const SCENE_TYPES = require("./scene-types");
-
-/**
- * The data type to encapsulate a loaded game.
- */
-class MtlGame {
-    /**
-     * Initialize the instance with default data.
-     */
-    constructor() {
-        this.name = "";
-        this.scenes = [new MtlScene()];
-
-        // Game interface styling preferences
-        this.game_ui_options = {
-            // The overall look of the innterface
-            general: {
-                background_color: "#000000",
-                animation_canvas_dimensions: {
-                    width: 600,
-                    ratio: "4:3",
-                    height: function () {
-                        const split_ratio = this.ratio.split(":").map(n => parseInt(n));
-                        const factor = split_ratio[1] / split_ratio[0];
-                        return Number((this.width * factor).toFixed());
-                    },
-                },
-            },
-            // The text box displayed on top of a scene image if a message is defined
-            text_boxes: {
-                background_color: "#222222",
-                font_size: 20,
-                font_style: "normal",
-                font_weight: "normal",
-                font_family: "Arial, sans-serif",
-                font_color: "#ffffff",
-                text_align: "left",
-                padding: 20,
-                margin: 20,
-                border_width: 0,
-                rounded_corners_radius: 0,
-            },
-            // The panel containing the choices buttons below the scene image.
-            choices_panel: {
-                background_color: "#222222",
-                font_size: 20,
-                font_family: "sans",
-                font_color: "#ffffff",
-                font_style: "normal",
-                text_align: "left",
-                font_weight: "normal",
-                container_padding: 20,
-                choice_padding: 10,
-                active_choice_background_color: "rgba(255,255,255,.4)",
-                active_choice_border_width: 0,
-                active_choice_rounded_corners_radius: 0,
-            },
-            // the inventory grid drawn at the right of the scene image
-            inventory: {
-                background_color: "#222222",
-                columns: 1,
-                rows: 4,
-                gap: 10,
-                padding: 20,
-                slot_rounded_corner_radius: 0,
-                slot_border_width: 2,
-            },
-        };
-
-        this.starting_scene_index = 0;
-
-        // The state of the running game
-        this.state = {
-            scene: this.starting_scene_index,
-            inventory: new Set(),
-            cinematic_timeout: {
-                inc: 0,
-                last_update_time: -1,
-                timeout: false,
-            },
-        };
-
-        // This state will be incremented for each when it finishes to load its image and sound resources.
-        // The loaded_scenes value must be equal to the scenes number for the game to consider that all resources are loaded.
-        this.load_state = {
-            loaded_scenes: 0,
-        };
-    }
-
-    /**
-     * Returns the scene that is currently displayed
-     * @returns {MtlScene}
-     */
-    get_scene() {
-        return this.scenes[this.state.scene];
-    }
-
-    /**
-     * @returns {MtlSoundTrack} the soundtrack that is set for the current scene
-     */
-    get_soundtrack() {
-        return this.get_scene().sound_track;
-    }
-
-    /**
-     * Updates the state with a new scene index.
-     * @param {Integer} index The index of the wanted scene
-     */
-    go_to_scene(index) {
-        const current_scene = this.get_scene();
-        current_scene.animation.reset_frame();
-        if (current_scene._type == SCENE_TYPES.CINEMATIC) {
-            this.state.cinematic_timeout = {
-                inc: 0,
-                last_update_time: -1,
-                timeout: false,
-            };
-        }
-        this.state.scene = index;
-    }
-
-    /**
-     * This is called if the current scene is of type Cinematic.
-     * UIncrements the cinematic_timeout counter and update the 
-     * cinematic_timeout.timeout state to true if the cinematic 
-     * has been running as much or more time than what is defined in scene.cinematic_duration.
-     */
-    update_cinematic_timeout() {
-        if (this.state.cinematic_timeout.timeout) return;
-
-        const t = new Date().getTime();
-
-        if (this.state.cinematic_timeout.last_update_time === -1) {
-            this.state.cinematic_timeout.last_update_time = t;
-        }
-
-        this.state.cinematic_timeout.inc = t - this.state.cinematic_timeout.last_update_time;
-        this.state.cinematic_timeout.timeout = this.get_scene().cinematic_duration * 1000 <= this.state.cinematic_timeout.inc;
-    }
-
-    /**
-     * Returns a state wether the running cinematic is ended or not
-     * @returns {Boolean}
-     */
-    is_cinematic_ended() {
-        const scene = this.get_scene();
-        if (scene.cinematic_duration === 0) return scene.animation.finished;
-        return this.state.cinematic_timeout.timeout;
-    }
-
-    /**
-     * Takes a game object as parameter and references it in the inventory state.
-     * @param {MtlGameObject} obj 
-     */
-    add_object_to_inventory(obj) {
-        this.state.inventory.add(obj);
-    }
-
-    /**
-     * @returns {Boolean} true if inventory has an empty slot.
-     */
-    inventory_has_empty_slot() {
-        const { columns, rows } = this.game_ui_options.inventory;
-        return this.state.inventory.size < columns * rows;
-    }
-
-    /**
-     * Take a game object as argument and removes its reference from the inventory state.
-     * @param {MtlGameObject} obj 
-     */
-    remove_object_from_inventory(obj) {
-        this.state.inventory.delete(obj);
-    }
-
-    /**
-     * Takes a game object as argument, removes the reference from inventory 7
-     * and delete the object from the scene it's defined into.
-     * @param {MtlGameObject} obj 
-     */
-    consume_game_object(obj) {
-        this.remove_object_from_inventory(obj);
-        for (const s of this.scenes) {
-            const found_obj = s.game_objects.find(o => o === obj);
-            if (found_obj) {
-                s.game_objects.splice(s.game_objects.indexOf(found_obj), 1);
-                break;
-            }
-        }
-    }
-
-    /**
-     * Returns true if the number of scenes having finished to load their loadable resources is equal to the total number of scenes.
-     * @returns {Boolean}
-     */
-    all_resources_loaded() {
-        return this.load_state.loadable_elements === this.load_state.loaded_elements;
-    }
-
-    /**
-     * Returns a concatenation of all game objects of all scenes.
-     * @returns {MtlGameObject[]}
-     */
-    get_game_objects() {
-        return this.scenes.reduce((acc, scene) => acc.concat(scene.game_objects), []);
-    }
-
-    /**
-     * Populates the instance with a litteral game desriptor.
-     * The expected data format is identical to what's exported from the Mentalo app editor,
-     * or what's returned from the Mentalo API database.
-     * @param {Object} data 
-     */
-    load_data(data) {
-        this.name = data.name;
-
-        this.starting_scene_index = data.starting_scene_index || 0;
-        this.state.scene = this.starting_scene_index;
-
-        this.scenes = data.scenes.map(scene => {
-            const scene_instance = new MtlScene();
-            scene_instance.load_data(scene);
-            scene_instance.on_load_group_complete(() => {
-                this.load_state.loaded_scenes++;
-                if (this.load_state.loaded_scenes === this.scenes.length) {
-                    this.on_load_resources_callback && this.on_load_resources_callback();
-                }
-            })
-            return scene_instance;
-        });
-
-        this.game_ui_options = Object.assign(data.game_ui_options, {
-            general: {
-                background_color: data.game_ui_options.general.background_color,
-                animation_canvas_dimensions: Object.assign(data.game_ui_options.general.animation_canvas_dimensions, {
-                    height: function () {
-                        const split_ratio = this.ratio.split(":").map(n => parseInt(n));
-                        const factor = split_ratio[1] / split_ratio[0];
-                        return Number((this.width * factor).toFixed());
-                    },
-                }),
-            }
-        });
-    }
-
-    /**
-     * Sets the callback to call when all resources of all scenes are fully loaded.
-     * @param {Function} callback 
-     */
-    on_load_resources(callback) {
-        this.on_load_resources_callback = callback;
-    }
-}
-
-module.exports = MtlGame;
-},{"./scene":19,"./scene-types":18}],16:[function(require,module,exports){
-"use strict";
-
-/**
- * A structure to manage a group of object that extends from the ./Loadable class.
- * For each handled loadable, increments a loadable_elements state, and once the loadable has finished 
- * to load whatever it needs to load, the loaded_elements state is incremented.
- */
-class LoadableGroup {
-    constructor() {
-        this.loadable_elements = 0;
-        this.loaded_elements = 0;
-    }
-
-    /**
-     * Attaches a on_load callback to the Loadable object given as argument.
-     * Increments the loadable_elements state.
-     * @param {Loadable} object Any object that extends the Loadable class
-     */
-    add_loadable(object) {
-        this.loadable_elements++;
-        /**
-         * The callback that will be call by the Loadable object when it will have finished to load what it needs to load.
-         */
-        object.on_load(() => {
-            this.loaded_elements++;
-            if (this.loadable_elements === this.loaded_elements && this.on_load_group_complete_custom_callback) {
-                this.on_load_group_complete_custom_callback();
-            }
-        });
-    }
-
-    /**
-     * Defines a custom callback to call when all handled loadable objects have finished loading.
-     * @param {Function} callback 
-     */
-    on_load_group_complete(callback) {
-        this.on_load_group_complete_custom_callback = callback;
-    }
-}
-
-module.exports = LoadableGroup;
-},{}],17:[function(require,module,exports){
-"use strict";
-
-/**
- * An abstract type that must be inherited by any object that have a resource to load.
- * Can be an image or a sound.
- */
-class Loadable {
-    /**
-     * Initialization of the actual loadable element.
-     * @param {HTMLElement} loadable_element Image() or Audio() for example
-     * @param {String} field_name the name of the field that references the loadable element in the instance.
-     * @param {String} event_name the name of the event that is dispatched by the loadable 
-     *                            html element when it has loaded a resource ("load" or "loadeddata")
-     */
-    constructor(loadable_element, field_name, event_name) {
-        this.loaded = false;
-        this.init_loadable_element(loadable_element, field_name, event_name);
-    }
-
-    /**
-     * Initializes event listeners for the registered loadable_element.
-     * @param {HTMLElement} loadable_element 
-     * @param {String} field_name the name of the field that references the element
-     * @param {String} event_name the event to use for resource loading (images uses onload, audio uses onloadeddata ...)
-     */
-    init_loadable_element(loadable_element, field_name, event_name) {
-        this[field_name] = loadable_element;
-
-        this.load_listener = () => {
-            this.loaded = true;
-            this.on_load_callback();
-            this.clear();
-        };
-
-        this[field_name].addEventListener(event_name, this.load_listener);
-
-        this.clear = () => {
-            this[field_name].removeEventListener(event_name, this.load_listener);
-        }
-    }
-
-    /**
-     * Sets a callback to call when the loadable_element has finished to load its resource.
-     * @param {Function} callback 
-     */
-    on_load(callback) {
-        this.on_load_custom_callback = callback;
-    }
-
-    /**
-     * The callback that's called when the loadable element as finished loading its resource
-     */
-    on_load_callback() {
-        this.loaded = true;
-        this.on_load_custom_callback && this.on_load_custom_callback();
-    }
-}
-
-module.exports = Loadable;
-},{}],18:[function(require,module,exports){
-/**
- * An enum like object to describe the 2 possible type that can have a MtlScene
- */
-
-module.exports = {
-    PLAYABLE: "Playable",
-    CINEMATIC: "Cinematic",
-};
-},{}],19:[function(require,module,exports){
-"use strict";
-
-const SCENE_TYPES = require("./scene-types");
-const MtlAnimation = require("./animation");
-const MtlSoundTrack = require("./sound-track");
-const MtlChoice = require("./choice");
-const MtlGameObject = require("./game-object");
-const LoadableGroup = require("./loadable-group");
-
-/**
- * The type used for the scenes of a MtlGame.
- * Extends the LoadableGroup class because it manages multiple objects of the class Loadable.
- */
-class MtlScene extends LoadableGroup {
-    /**
-     * Initializes the instance with default empty data.
-     */
-    constructor() {
-        super();
-        const default_data = {
-            name: "",
-            _type: SCENE_TYPES.PLAYABLE,
-            animation: new MtlAnimation(),
-            sound_track: new MtlSoundTrack(),
-            choices: [],
-            text_box: "",
-            game_objects: [],
-            cinematic_duration: 0,
-            end_cinematic_options: {
-                destination_scene_index: -1,
-                quit: false,
-            }
-        };
-
-        this.name = default_data.name;
-        this._type = default_data._type;
-        this.animation = default_data.animation;
-        this.sound_track = default_data.sound_track;
-
-        this.choices = default_data.choices;
-        this.text_box = default_data.text_box;
-        this.game_objects = default_data.game_objects;
-
-        this.cinematic_duration = default_data.cinematic_duration || 0;
-        this.end_cinematic_options = default_data.end_cinematic_options;
-    }
-
-    /**
-     * Populates the instance from a litteral descriptor.
-     * Creates and populates the instances of Animation, Soundtrack and GameObjects
-     * @param {Object} data 
-     */
-    load_data(data) {
-        this.name = data.name;
-        this._type = data._type;
-
-        const animation = new MtlAnimation();
-        animation.init(data.animation);
-        this.animation.clear();
-        this.animation = animation;
-        this.animation.image.src && this.add_loadable(this.animation);
-
-        const sound_track = new MtlSoundTrack();
-        sound_track.init(data.sound_track);
-        this.sound_track.clear();
-        this.sound_track = sound_track;
-        this.sound_track.src && this.add_loadable(this.sound_track);
-
-        this.choices = data.choices.map(c => new MtlChoice(c));
-        this.text_box = data.text_box;
-        this.cinematic_duration = data.cinematic_duration || 0;
-
-        this.game_objects = data.game_objects.map(gob => {
-            const inst = new MtlGameObject();
-            inst.load_data(gob);
-            this.add_loadable(inst);
-            return inst;
-        });
-
-        this.end_cinematic_options = data.end_cinematic_options;
-    }
-}
-
-module.exports = MtlScene;
-},{"./animation":12,"./choice":13,"./game-object":14,"./loadable-group":16,"./scene-types":18,"./sound-track":20}],20:[function(require,module,exports){
-"use strict";
-
-const Loadable = require("./loadable");
-
-/**
- * The data type used to represent and manage the soundtrack of a scene
- */
-class MtlSoundTrack extends Loadable {
-    constructor() {
-        super(new Audio(), "audio", "loadeddata");
-        this.name = "";
-        this.initialized = false;
-    }
-
-    /**
-     * Reset the instance to empty values.
-     */
-    reset() {
-        this.init_loadable_element(new Audio(), "audio", "loadeddata");
-        this.name = "";
-        this.initialized = false;
-    }
-
-    /**
-     * Populates the instance from a litteral descriptor
-     * @param {Object} data 
-     */
-    init(data) {
-        this.empty = data.src === "";
-        this.audio.src = data.src;
-        this.name = data.name;
-        this.audio.loop = data._loop;
-        this.initialized = true;
-    }
-
-    /**
-     * Plays the audio resource.
-     */
-    play() {
-        this.audio.play();
-    }
-
-    /**
-     * Stops the audio player.
-     */
-    stop() {
-        this.audio.pause();
-        this.audio.currentTime = 0;
-    }
-
-    /**
-     * Returns the state of the inner audio element
-     * @returns {Boolean}
-     */
-    is_playing() {
-        return !this.audio.paused
-    }
-}
-
-module.exports = MtlSoundTrack;
-},{"./loadable":17}],21:[function(require,module,exports){
-"use strict";
-
-const ChoicesPanelMetrics = require("./choices-panel-metrics");
-
-class CanvasDimensions {
-    constructor(params) {
-        this.params = params;
-
-        // The original dimensions of the image part of the canvas
-        this.image = { width: 0, height: 0 };
-
-        // The scaled dimensions of the rendering canvas
-        this.width = 0;
-        this.height = 0;
-
-        // Scaled dimensions of the inventory zone
-        this.inventory = { width: 0, height: 0 };
-
-        // Scaled dimensions of the choices zone
-        this.choices_panel = { width: 0, height: 0 };
-
-        // The scale factor that will have been calculated
-        this.scale_factor = 1;
-
-        this.init_dimensions();
-    }
-
-    /**
-     * Calculate the dimensions from game settings
-     */
-    init_dimensions() {
-        const { get_game_settings } = this.params;
-
-        const general_settings = get_game_settings("general");
-
-        const image_h = general_settings.animation_canvas_dimensions.height();
-        const image_w = general_settings.animation_canvas_dimensions.width;
-        const inventory_w = this.get_inventory_base_width();
-        const base_w = image_w + inventory_w;
-
-        this.choices_panel_metrics = this.get_choices_panel_metrics();
-
-        const max_playground_width = window.screen.width - 200;
-        const max_playground_height = window.screen.height - 200;
-
-        const base_h = image_h + this.choices_panel_metrics.get_total_height();
-        const playground_ratio = base_w / base_h;
-
-        const screen_ratio = window.screen.width / window.screen.height;
-
-        // Calculate the width of the canvas to the maximum possible scale
-        if (screen_ratio > playground_ratio) {
-            // Screen is more panoramic than game canvas so we scale canvas to the maximum height
-            const scaled_w = max_playground_height * playground_ratio
-            this.width = scaled_w <= max_playground_width ? scaled_w : max_playground_width;
-        } else {
-            // Image is more panoramic so its scaled to the maximum width
-            const scaled_h = max_playground_width * (base_h / base_w)
-            this.height = scaled_h <= max_playground_height ? scaled_h : max_playground_height;
-            this.width = this.height * playground_ratio;
-        }
-
-        this.scale_factor = this.width / base_w;
-
-        this.choices_panel_metrics = this.choices_panel_metrics.to_scaled(this.scale_factor);
-
-        this.image = {
-            width: image_w * this.scale_factor,
-            height: image_h * this.scale_factor,
-        };
-
-        this.inventory = {
-            width: inventory_w * this.scale_factor,
-            height: this.image.height,
-        };
-
-        this.choices_panel = {
-            width: this.width,
-            height: this.choices_panel_metrics.get_total_height(),
-        };
-
-        this.height = this.image.height + this.choices_panel.height;
-    }
-
-    /**
-     * The width of the inventory panel
-     * @returns {Integer}
-     */
-    get_inventory_base_width() {
-        const { get_game_settings } = this.params;
-        const image_h = get_game_settings("general").animation_canvas_dimensions.height();
-        const inventory_style = get_game_settings("inventory");
-        const gap = inventory_style.gap;
-        const h = image_h - (2 * inventory_style.padding);
-        const gap_h = (inventory_style.rows - 1) * gap;
-        const gap_w = (inventory_style.columns - 1) * gap
-        const slot_side = (h - gap_h) / inventory_style.rows;
-        return (inventory_style.columns * slot_side)
-            + (2 * inventory_style.padding)
-            + gap_w;
-    }
-
-    /**
-     * The metrics in pixels for each row of choices in the choices panel
-     * @returns {ChoicesPanelMetrics}
-     */
-    get_choices_panel_metrics() {
-        const { get_game_settings, get_game_scenes } = this.params;
-        const choices_panel_settings = get_game_settings("choices_panel");
-        const general_settings = get_game_settings("general");
-
-        const container_padding = choices_panel_settings.container_padding;
-        const container_width = general_settings.animation_canvas_dimensions.width
-            + this.get_inventory_base_width()
-            - (2 * container_padding);
-
-        const scenes_choices = get_game_scenes().map(s => { return { choices: s.choices } });
-
-        return new ChoicesPanelMetrics({
-            settings: choices_panel_settings,
-            container_width,
-            scenes_choices,
-        });
-    }
-}
-
-module.exports = CanvasDimensions;
-},{"./choices-panel-metrics":22}],22:[function(require,module,exports){
-"use strict";
-
-const { get_canvas_char_size } = require("../lib/font-tools");
-
-/**
- * A class to facilitate the handling of the chocies panel outer and inner dimensions
- */
-class ChoicesPanelMetrics {
-    constructor(params) {
-        this.params = params;
-        const { scale_factor = 1 } = this.params;
-        this.container_width = this.params.container_width * scale_factor;
-        this.settings = this.get_scaled_settings(scale_factor);
-        this.scenes_formatted_choices = this.get_scenes_formatted_choices();
-        this.container_padding_height = 2 * this.settings.container_padding;
-        this.rows = this.get_rows_metrics();
-    }
-
-    /**
-     * @returns {object} the choices panel settings object given in the instance parameters 
-     * scaled by the scale_factor parameter
-     */
-    get_scaled_settings(scale_factor) {
-        const { settings } = this.params;
-        return Object.assign({ ...settings }, {
-            font_size: settings.font_size * scale_factor,
-            active_choice_border_width: settings.active_choice_border_width * scale_factor,
-            active_choice_rounded_corners_radius: settings.active_choice_rounded_corners_radius * scale_factor,
-            choice_padding: settings.choice_padding * scale_factor,
-            container_padding: settings.container_padding * scale_factor,
-        });
-    }
-
-    /**
-     * @param {Number} row_nb Optional - 1 or 2 if we don't want to get the number of choice rows at its max
-     * @param {Object[]} scene_choices Optional - The choices that we want to parse, default is all choices.
-     * @returns {Object[]} An array of 2 objects like {text_height, padding_height} for each choices row
-     */
-    get_rows_metrics(row_nb, scene_choices) { // params will be default if undefined
-        const { settings } = this;
-        const max_lines_per_row = this.get_choices_max_lines_per_row(row_nb, scene_choices);
-
-        const char_size = get_canvas_char_size(window.mentalo_drawing_context, settings);
-        const text_line_h = char_size.text_line_height;
-        const interline_h = char_size.interline_height;
-
-        const choice_padding_height = 2 * settings.choice_padding;
-
-        const height_text_row_1 = max_lines_per_row[0] * text_line_h - interline_h;
-        const height_text_row_2 = max_lines_per_row[1] > 0 // If some scene have more than 2 choices, there will be displayed on 2 rows.
-            ? max_lines_per_row[1] * text_line_h - interline_h
-            : 0;
-
-        return [
-            {
-                text_height: height_text_row_1,
-                padding_height: choice_padding_height
-            },
-            {
-                text_height: height_text_row_2,
-                padding_height: height_text_row_2 > 0 ? choice_padding_height : 0
-            }
-        ];
-    }
-
-    /** 
-     * The maximum number of text lines that each row of choice can have
-     * @returns {Integer[]} An array with 2 items. 1st is for the upper choices row, second is for the lower one.
-     */
-    get_choices_max_lines_per_row(row_nb, scenes_choices) {
-        const choices_max_row_nb = row_nb || this.get_max_choices_row_per_scene();
-        const formatted_choices = scenes_choices || this.scenes_formatted_choices;
-
-        // get the largest number of text lines per choice row
-        const max_lines_per_row = [0, 0];
-        Array.from({ length: choices_max_row_nb }).forEach((_row, i) => {
-            const slice_index = i === 0 ? [0, 2] : [2, 4];
-            const largest = formatted_choices
-                .map(s_choices => Math.max(...s_choices
-                    .slice(...slice_index)
-                    .map(c => c.text_lines.length)
-                ))
-                .reduce((res, nb) => Math.max(res, nb), 0);
-            max_lines_per_row[i] = largest;
-        });
-
-        return max_lines_per_row;
-    }
-
-    /**
-     * @returns {Integer} The maximum number of rows that the choices_panel can have in the loaded game.
-     */
-    get_max_choices_row_per_scene(scenes_slice_index) {
-        const scenes_choices = scenes_slice_index
-            ? this.params.scenes_choices.slice(scenes_slice_index, scenes_slice_index + 1)
-            : this.params.scenes_choices;
-
-        let choices_max_row_nb = 1;
-        if (Math.max(...scenes_choices.map(s => s.choices.length)) > 2) {
-            choices_max_row_nb = 2
-        }
-
-        return choices_max_row_nb;
-    }
-
-    /**
-     * Parses the raw text of each scene choices and returns them with an additional 
-     * text_lines field with the text split into lines ready to be rendered in canvas.
-     * @typedef FormattedChoice
-     * @property {String[]} text_lines
-     * @property {...MtlChoice} - the other MtlChoice fields
-     * 
-     * @returns {FormattedChoice[]}
-     */
-    get_scenes_formatted_choices() {
-        const { scenes_choices } = this.params;
-        const { settings, container_width } = this;
-
-        const char_size = get_canvas_char_size(window.mentalo_drawing_context, settings);
-
-        const { choice_padding } = settings;
-        const error_offset = .9;
-        const choice_max_width = ((container_width / 2) - (2 * choice_padding)) * error_offset;
-        const max_chars_per_row = choice_max_width / char_size.width;
-
-        return scenes_choices.map(s => s.choices.map(c => {
-            const words = c.text.split(" ");
-            const lines = [""];
-            let line_i = 0;
-
-            words.forEach(w => {
-                if ((lines[line_i] + w).length >= max_chars_per_row) {
-                    line_i++;
-                    lines.push("");
-                }
-                lines[line_i] = `${lines[line_i]}${lines[line_i] === "" ? "" : " "}${w}`;
-            });
-            return Object.assign({ ...c }, { text_lines: lines });
-        }));
-    }
-
-    /**
-     * The total calculated height of the choices panel
-     * @returns {Number}
-     */
-    get_total_height(rows) {
-        rows = rows || this.rows;
-        return rows.reduce(
-            (total, row) => total + row.text_height + row.padding_height, 0
-        ) + this.container_padding_height;
-    }
-
-    /**
-     * A copy of this instance with values scaled by the given factor
-     * @param {Number} scale_factor the scaling factor
-     * @returns {ChoicesPanelMetrics}
-     */
-    to_scaled(scale_factor) {
-        return new ChoicesPanelMetrics({ ...this.params, scale_factor });
-    }
-
-    /**
-     * The choices panel bounding zone has first been calculated to the maximum, 
-     * but certain scenes may need less space to display their choices.
-     * This return the only necessary height for a given scene
-     * @param {MtlChoice[]} scene_choices 
-     * @returns {Number} A bounding box {top, left, right, bottom, width, height}
-     */
-    get_minimum_panel_height_for_scene(scene_index) {
-        const scene_choices = this.scenes_formatted_choices.slice(scene_index, scene_index + 1);
-        const choices_row_nb = scene_choices[0].length > 2 ? 2 : 1;
-        const rows = this.get_rows_metrics(choices_row_nb, scene_choices);
-        return this.get_total_height(rows);
-    }
-
-    /**
-     * Get the metrics for a given row of choices
-     * @param {Integer} scene_index 
-     * @param {Number} scale_fator 
-     */
-    get_one_choices_row_metrics(scene_index, row_index) {
-        const scene_choices = this.scenes_formatted_choices.slice(scene_index, scene_index + 1);
-        const rows = this.get_rows_metrics(row_index + 1, scene_choices);
-        const row = rows[row_index];
-        return Object.assign(row, {
-            text_height: row.text_height,
-            padding_height: row.padding_height,
-        });
-    }
-}
-
-module.exports = ChoicesPanelMetrics;
-},{"../lib/font-tools":7}],23:[function(require,module,exports){
-"use strict";
-
-const FrameRateController = require("../lib/frame-rate-controller");
-const { get_optimal_visible_foreground_color } = require("../lib/color-tools");
-const { get_canvas_char_size } = require("../lib/font-tools");
-const SCENE_TYPES = require("../model/scene-types");
-const ChoiceCpt = require("./ui-components/choice-cpt");
-const ChoicesPanelCpt = require("./ui-components/choices-panel-cpt");
-const ClosingIconCpt = require("./ui-components/closing-icon-cpt");
-const GameObjectCpt = require("./ui-components/game-object-cpt");
-const InventoryCpt = require("./ui-components/inventory-cpt");
-const InventoryObjectCpt = require("./ui-components/inventory-object-cpt");
-const InventorySlotCpt = require("./ui-components/inventory-slot-cpt");
-const SceneAnimationCpt = require("./ui-components/scene-animation-cpt");
-const TextBoxCpt = require("./ui-components/text-box-cpt");
-const UserErrorPopup = require("./ui-components/user-error-popup");
-const CanvasDimensions = require("./canvas-dimensions");
-
-/**
- * The class that handles all interactions with the canvas 2D drawing context to draw the game.
- */
-class MtlRender {
-    constructor(params) {
-        this.params = params;
-        const frame_rate = this.params.frame_rate || 30;
-        this.fps_controller = new FrameRateController(frame_rate);
-
-        this.params.container.style.backgroundColor = this.params.get_game_settings("general").background_color;
-        this.canvas = document.createElement("canvas");
-        this.canvas.style.backgroundColor = "black";
-
-        window.mentalo_drawing_context = this.canvas.getContext("2d");
-
-        this.canvas_dimensions = {};
-        this.canvas_zones = {};
-        this.event_listeners = {
-            game_objects: [],
-            inventory: [],
-            text_box: [],
-        };
-
-        this.state = {
-            user_error_popup: { is_set: false, text: "", on_close: function () { } }
-        };
-
-        this.loading_frame = 0;
-    }
-
-    /**
-     * Initializes the rendering of the game, creates the components, the canvas zones and requests full screen.
-     */
-    init() {
-        if (this.params.fullscreen) {
-            this.set_full_screen();
-        }
-
-        this.set_canvas_dimensions();
-        this.create_canvas_zones();
-
-        this.params.container.appendChild(this.canvas);
-
-        const ctx = window.mentalo_drawing_context;
-        // Obsolete properties for browsers compatibility
-        ctx.mozImageSmoothingEnabled = false;
-        ctx.webkitImageSmoothingEnabled = false;
-        ctx.msImageSmoothingEnabled = false;
-        ctx.imageSmoothingEnabled = false;
-
-        this.create_components();
-    }
-
-    /**
-     * Removes the event listeners attached to the components.
-     */
-    clear_event_listeners() {
-        Object.values(this.components).forEach(cpt => {
-            cpt.clear_event_listeners();
-        });
-    }
-
-    /**
-     * Removes the elements rendered as children of the rendering components 
-     * (Example a TextBoxCpt is a child the scene_animation component)
-     * @param {Object} options can defined a set of constructor names to exclude from clean up
-     */
-    clear_children(options = { exclude: [] }) {
-        Object.values(this.components).forEach(cpt => {
-            cpt.clear_children(options);
-        });
-    }
-
-    /**
-     * Reset the scene text box visibility state to true.
-     * The text box visibility state is controlled by the scene_animation component and not directly by the text box component.
-     */
-    reset_text_box_visibility() {
-        this.components.scene_animation.set_text_box_visibility(true)
-    }
-
-    /**
-     * Tries to enable the window fullscreen mode.
-     */
-    set_full_screen() {
-        const body = document.body;
-        body.requestFullScreen = body.requestFullScreen
-            || body.webkitRequestFullScreen
-            || body.msRequestFullscreen
-            || body.mozRequestFullScreen;
-        try {
-            body.requestFullScreen();
-        } catch (err) {
-            console.error(err.message)
-        }
-    }
-
-    /**
-     * Exits the window fullscreen mode
-     */
-    exit_fullscreen() {
-        if (document.fullscreenElement) {
-            document.exitFullscreen();
-        }
-    }
-
-    /**
-     * Updates the error popup state with new values given as argument.
-     * @param {Object} params Must have a text<String> entry and a on_close<Function> entry.
-     */
-    set_user_error_popup(params) {
-        this.state.user_error_popup = {
-            is_set: true,
-            text: params.text,
-            stream_text: !!params.stream_text,
-            on_close: function () {
-                params.on_close && params.on_close();
-            },
-        };
-        this.clear_event_listeners();
-        // Clears children excluding everything but errorpopup
-        this.clear_children({ exclude: ["TextBoxCpt", "InventoryCpt", "ChoicesPanelCpt", "GameObjectCpt"] });
-    }
-
-    /**
-     * Unsets error popup
-     */
-    clear_user_error_popup() {
-        this.reset_user_error_popup();
-        this.clear_event_listeners();
-        this.clear_children({ exclude: ["TextBoxCpt", "InventoryCpt", "ChoicesPanelCpt", "GameObjectCpt"] });
-    }
-
-    /**
-     * Sets error popup to empty values
-     */
-    reset_user_error_popup() {
-        this.state.user_error_popup = {
-            is_set: false,
-            text: "",
-            on_close: function () { }
-        };
-    }
-
-    /**
-     * Callback called when error popup is closed
-     */
-    on_close_user_error_popup() {
-        this.state.user_error_popup.on_close();
-        this.clear_user_error_popup();
-    }
-
-    /**
-     * Parses game data and calculates optimal dimensions for the canvas.
-     */
-    set_canvas_dimensions() {
-        const { get_game_settings, get_game_scenes } = this.params;
-
-        this.canvas_dimensions = new CanvasDimensions({
-            get_game_settings,
-            get_game_scenes,
-        });
-
-        this.canvas.width = this.canvas_dimensions.width;
-        this.canvas.height = this.canvas_dimensions.height;
-    }
-
-    /**
-     * @returns {Object} The choices_panel settings scaled to display canvas size
-     */
-    get_scaled_choices_panel_settings() {
-        return this.canvas_dimensions.choices_panel_metrics.settings;
-    }
-
-    /**
-     * @returns {Object} The inventory settings scaled to display canvas size
-     */
-    get_scaled_inventory_settings() {
-        const { scale_factor } = this.canvas_dimensions;
-        const { get_game_settings } = this.params;
-
-        const base_settings = get_game_settings("inventory");
-
-        return Object.assign({ ...base_settings }, {
-            slot_rounded_corner_radius: base_settings.slot_rounded_corner_radius * scale_factor,
-            slot_border_width: base_settings.slot_border_width * scale_factor,
-            gap: base_settings.gap * scale_factor,
-            padding: base_settings.padding * scale_factor,
-        });
-    }
-
-    /**
-     * @returns {Object} The text boxes settings scaled to display canvas size
-     */
-    get_scaled_text_boxes_settings() {
-        const { scale_factor } = this.canvas_dimensions;
-        const { get_game_settings } = this.params;
-        const text_box_settings = get_game_settings("text_boxes");
-
-        return Object.assign({ ...text_box_settings }, {
-            font_size: text_box_settings.font_size * scale_factor,
-            padding: text_box_settings.padding * scale_factor,
-            margin: text_box_settings.margin * scale_factor,
-            rounded_corners_radius: text_box_settings.rounded_corners_radius * scale_factor,
-            border_width: text_box_settings.border_width * scale_factor,
-        })
-    }
-
-    /**
-     * Precalculates the bounding boxes for each canvas zone.
-     * set_canvas_dimensions() must have been called before this.
-     */
-    create_canvas_zones() {
-        const metrics = this.canvas_dimensions;
-        const inventory_settings = this.get_scaled_inventory_settings();
-        const choices_panel_settings = this.get_scaled_choices_panel_settings();
-
-        this.canvas_zones = {
-            root: {
-                left: 0,
-                top: 0,
-                right: metrics.width,
-                bottom: metrics.height,
-                width: metrics.width,
-                height: metrics.height,
-                padding: 0,
-            },
-            scene_animation: {
-                left: 0,
-                top: 0,
-                right: metrics.image.width,
-                bottom: metrics.image.height,
-                width: metrics.image.width,
-                height: metrics.image.height,
-                clear_color: "black",
-                padding: 0,
-            },
-            inventory: {
-                left: metrics.image.width,
-                top: 0,
-                right: metrics.width,
-                bottom: metrics.inventory.height,
-                width: metrics.width - metrics.image.width,
-                height: metrics.inventory.height,
-                clear_color: inventory_settings.background_color,
-                padding: inventory_settings.padding,
-            },
-            choices_panel: {
-                left: 0,
-                top: metrics.image.height,
-                right: metrics.width,
-                bottom: metrics.height,
-                width: metrics.width,
-                height: metrics.height - metrics.image.height,
-                clear_color: choices_panel_settings.background_color,
-                padding: choices_panel_settings.container_padding
-            },
-        };
-    }
-
-    /**
-     * @returns {Boolean} true if scene is of type Playable
-     */
-    scene_is_not_cinematic() {
-        return this.params.get_scene()._type === SCENE_TYPES.PLAYABLE;
-    }
-
-    /**
-     * Creates the tree of all the components to render. All component extends the UiComponent class.
-     * The root component instances (Scene animation, Inventory, Choices panel) will only be created once,
-     * but children components are functions of their parents so they are recreated when get_children is called on a parent component.
-     * For example A TextBoxCpt is a children a SceneAnimationCpt, so the text box component will be recreated each time 
-     * scene_animation.get_children() is called. This allow to display those components as functions of dynamical states.
-     */
-    create_components() {
-        const { get_game_settings, get_inventory, get_scene, get_game_scenes, get_scene_index } = this.params;
-        const { scale_factor } = this.canvas_dimensions;
-        const scene_is_not_cinematic = this.scene_is_not_cinematic.bind(this);
-        this.components = {
-            scene_animation: new SceneAnimationCpt({
-                bounding_zone: this.canvas_zones.scene_animation,
-                get_animation: () => get_scene().animation,
-                next_frame_ready: () => this.fps_controller.nextFrameReady(),
-                get_children: () => {
-                    const scene = get_scene();
-                    return scene.game_objects.map(o => {
-                        const parent_zone = this.canvas_zones.scene_animation;
-                        const obj_pos = {
-                            x: (o.position.x * scale_factor) + parent_zone.left,
-                            y: (o.position.y * scale_factor) + parent_zone.top,
-                        };
-
-                        const obj_dim = {
-                            w: o.image.width * scale_factor,
-                            h: o.image.height * scale_factor,
-                        };
-
-                        const obj_bounds = {
-                            left: obj_pos.x,
-                            right: obj_pos.x + obj_dim.w,
-                            top: obj_pos.y,
-                            bottom: obj_pos.y + obj_dim.h,
-                            width: obj_dim.w,
-                            height: obj_dim.h,
-                        };
-
-                        const game_objects_cpt_params = {
-                            bounding_zone: obj_bounds,
-                            position: obj_pos,
-                            dimensions: obj_dim,
-                            image: o.image,
-                            is_in_inventory: () => get_inventory().has(o),
-                        };
-
-                        const obj_cpt = new GameObjectCpt(game_objects_cpt_params);
-
-                        obj_cpt.add_event_listener({
-                            event_type: "mousemove",
-                            listener: e => {
-                                if (!get_inventory().has(o)) {
-                                    const cursor_is_over_obj = e.offsetX >= obj_bounds.left
-                                        && e.offsetX <= obj_bounds.right
-                                        && e.offsetY >= obj_bounds.top
-                                        && e.offsetY <= obj_bounds.bottom;
-                                    obj_cpt.state.draw_border = cursor_is_over_obj;
-                                }
-                            }
-                        });
-
-                        obj_cpt.add_event_listener({
-                            event_type: "click",
-                            listener: e => {
-                                if (!get_inventory().has(o)
-                                    && e.offsetX >= obj_bounds.left
-                                    && e.offsetX <= obj_bounds.right
-                                    && e.offsetY >= obj_bounds.top
-                                    && e.offsetY <= obj_bounds.bottom) {
-                                    this.params.on_game_object_click(o);
-                                    this.clear_event_listeners();
-                                    this.clear_children({ exclude: "TextBoxCpt" });
-                                }
-                            }
-                        });
-
-                        return obj_cpt;
-                    })
-                        .concat(scene.text_box ? [
-                            (() => {
-                                const text_box_settings = this.get_scaled_text_boxes_settings();
-
-                                // calculation of the minimum bound for textbox, without content
-                                const text_box_bounds = (() => {
-                                    const parent_zone = this.canvas_zones.scene_animation;
-                                    const padding = text_box_settings.padding;
-                                    const margin = text_box_settings.margin;
-                                    const left = parent_zone.left + margin;
-                                    const right = parent_zone.right - margin;
-                                    const bottom = parent_zone.bottom - margin;
-                                    const top = bottom - (2 * padding);
-                                    return {
-                                        left,
-                                        right,
-                                        top,
-                                        bottom,
-                                        width: right - left,
-                                        height: bottom - top,
-                                    }
-                                })();
-
-                                const closing_icon_radius = 10 * scale_factor;
-                                const closing_icon_center = {
-                                    x: text_box_bounds.right - (closing_icon_radius / 2),
-                                    y: text_box_bounds.top + (closing_icon_radius / 2),
-                                };
-
-                                const closing_icon_params = {
-                                    color: text_box_settings.font_color,
-                                    radius: closing_icon_radius,
-                                    center: closing_icon_center,
-                                    background_color: text_box_settings.background_color,
-                                    line_width: Math.floor(2 * scale_factor),
-                                    bounding_zone: {
-                                        left: closing_icon_center.x - closing_icon_radius,
-                                        right: closing_icon_center.x + closing_icon_radius,
-                                        top: closing_icon_center.y - closing_icon_radius,
-                                        bottom: closing_icon_center.y + closing_icon_radius,
-                                        width: 2 * closing_icon_radius,
-                                        height: 2 * closing_icon_radius,
-                                    },
-                                };
-
-                                const text_box = new TextBoxCpt({
-                                    text: get_scene().text_box,
-                                    settings: text_box_settings,
-                                    bounding_zone: text_box_bounds,
-                                    get_visibility_state: () => this.components.scene_animation.state.text_box_visible,
-                                    get_children: () => [new ClosingIconCpt(closing_icon_params)],
-                                });
-
-                                const close_text_box_icon = text_box.children[0];
-                                text_box.children[0].add_event_listener({
-                                    event_type: "click",
-                                    listener: e => {
-                                        const bounds = close_text_box_icon.params.bounding_zone;
-                                        const click_over_icon = e.offsetX >= bounds.left
-                                            && e.offsetX <= bounds.right
-                                            && e.offsetY >= bounds.top
-                                            && e.offsetY <= bounds.bottom;
-
-                                        const { scene_animation } = this.components;
-
-                                        if (click_over_icon && scene_animation.state.text_box_visible) {
-                                            scene_animation.set_text_box_visibility(false);
-                                        }
-                                    }
-                                });
-                                return text_box;
-                            })(),
-                        ] : [])
-                        .concat(this.state.user_error_popup.is_set ? [
-                            (() => {
-                                const { text, stream_text } = this.state.user_error_popup;
-                                const use_settings = this.get_scaled_text_boxes_settings();
-                                const parent_bounds = this.canvas_zones.scene_animation
-                                const char_size = get_canvas_char_size(window.mentalo_drawing_context, use_settings);
-
-                                const popup_width = parent_bounds.width / 2;
-                                const padding = char_size.width * 2;
-                                const max_chars_per_row = (popup_width - (2 * padding)) / char_size.width;
-                                const text_lines = [""];
-
-                                let line_i = 0;
-                                text.split(" ").forEach(word => {
-                                    if (`${text_lines[line_i]}${word} `.length > max_chars_per_row) {
-                                        line_i++;
-                                        text_lines.push("");
-                                    }
-                                    text_lines[line_i] += `${word} `;
-                                });
-
-                                const line_h = char_size.text_line_height;
-                                const popup_height = (text_lines.length * line_h) + (2 * padding) - char_size.interline_height;
-
-                                const background_color = use_settings.background_color;
-                                const text_color = use_settings.font_color;
-
-                                const popup_bounds = {
-                                    left: parent_bounds.left + (parent_bounds.width / 2) - (popup_width / 2),
-                                    top: parent_bounds.top + (parent_bounds.height / 2) - (popup_height / 2),
-                                    right: parent_bounds.left + (parent_bounds.width / 2) + (popup_width / 2),
-                                    bottom: parent_bounds.top + (parent_bounds.height / 2) - (popup_height / 2) + popup_height,
-                                    width: popup_width,
-                                    height: popup_height,
-                                    clear_color: background_color,
-                                };
-
-                                const popup_params = {
-                                    settings: use_settings,
-                                    bounding_zone: popup_bounds,
-                                    modal_bounds: this.canvas_zones.scene_animation,
-                                    clear_modal_color: "#0004",
-                                    text_lines,
-                                    padding,
-                                    stream_text,
-                                    get_children: () => {
-                                        const center = {
-                                            x: popup_bounds.right - 5,
-                                            y: popup_bounds.top + 5,
-                                        };
-
-                                        const radius = 15;
-
-                                        const closing_icon_bounds = {
-                                            left: center.x - radius,
-                                            right: center.x + radius,
-                                            top: center.y - radius,
-                                            bottom: center.y + radius,
-                                            width: radius * 2,
-                                            height: radius * 2,
-                                        };
-
-                                        const closing_icon_params = {
-                                            color: text_color,
-                                            radius,
-                                            center,
-                                            background_color,
-                                            line_width: 2,
-                                            bounding_zone: closing_icon_bounds,
-                                        };
-
-                                        const closing_icon = new ClosingIconCpt(closing_icon_params);
-
-                                        closing_icon.add_event_listener({
-                                            event_type: "click",
-                                            listener: e => {
-                                                if (e.offsetX >= closing_icon_bounds.left
-                                                    && e.offsetX <= closing_icon_bounds.right
-                                                    && e.offsetY >= closing_icon_bounds.top
-                                                    && e.offsetY <= closing_icon_bounds.bottom) {
-                                                    this.on_close_user_error_popup();
-                                                }
-                                            }
-                                        });
-
-                                        return [closing_icon];
-                                    }
-                                };
-                                const popup = new UserErrorPopup(popup_params);
-                                return popup;
-                            })()
-                        ] : []);
-                },
-            }),
-            inventory: new InventoryCpt({
-                bounding_zone: this.canvas_zones.inventory,
-                get_inventory,
-                is_visible: scene_is_not_cinematic,
-                invisible_clear_color: get_game_settings("general").background_color,
-                get_children: () => {
-                    const settings = this.get_scaled_inventory_settings();
-
-                    const slots_zone = {
-                        left: this.canvas_zones.inventory.left + settings.padding,
-                        top: this.canvas_zones.inventory.top + settings.padding,
-                        right: this.canvas_zones.inventory.right - settings.padding,
-                        bottom: this.canvas_zones.inventory.bottom - settings.padding,
-                        width: this.canvas_zones.inventory.width - (2 * settings.padding),
-                        height: this.canvas_zones.inventory.height - (2 * settings.padding)
-                    };
-
-                    const gap = settings.gap;
-                    const h = slots_zone.height;
-                    const gap_h = (settings.rows - 1) * gap;
-                    const slot_side = (h - gap_h) / settings.rows;
-                    const slots_total_w = (slot_side * settings.columns) + (gap * (settings.columns - 1));
-                    const center_slot_x_offset = (slots_zone.width - (slots_total_w)) / 2;
-
-                    const slots = [];
-                    let slot_i = 0;
-                    Array.from({ length: settings.rows }).forEach((_row, i) => {
-                        const top = slots_zone.top + (i * (slot_side + gap));
-                        Array.from({ length: settings.columns }).forEach((_col, j) => {
-                            const left = slots_zone.left + (j * (slot_side + gap)) + center_slot_x_offset;
-
-                            const slot_bounds = {
-                                left,
-                                top,
-                                right: left + slot_side,
-                                bottom: top + slot_side,
-                                width: slot_side,
-                                height: slot_side
-                            };
-
-                            const stroke_color = get_optimal_visible_foreground_color(settings.background_color);
-
-                            const slot_params = {
-                                bounding_zone: slot_bounds,
-                                stroke_color,
-                                is_visible: scene_is_not_cinematic,
-                                settings,
-                                get_children: () => {
-                                    const inventory_object = new InventoryObjectCpt({
-                                        slot_index: slot_i,
-                                        is_visible: scene_is_not_cinematic,
-                                        settings,
-                                        stroke_color,
-                                        get_game_object: index => {
-                                            const obj = Array.from(get_inventory())[index];
-                                            if (obj) {
-                                                const obj_dim = obj.get_dimensions();
-                                                const obj_ratio = obj_dim.width / obj_dim.height;
-                                                const scaled_dim = { width: 0, height: 0 };
-                                                const pos = { x: 0, y: 0 };
-                                                if (obj_ratio > 1) {
-                                                    // object image landscape oriented
-                                                    scaled_dim.width = slot_side;
-                                                    scaled_dim.height = slot_side * (obj_dim.height / obj_dim.width);
-                                                    pos.x = slot_bounds.left;
-                                                    pos.y = slot_bounds.top + ((slot_side / 2) - (scaled_dim.height / 2));
-                                                } else if (obj_ratio < 1) {
-                                                    // portrait oriented
-                                                    scaled_dim.height = slot_side;
-                                                    scaled_dim.width = slot_side * obj_ratio;
-                                                    pos.y = slot_bounds.top;
-                                                    pos.x = slot_bounds.left + ((slot_side / 2) - (scaled_dim.width / 2));
-                                                } else {
-                                                    // square
-                                                    scaled_dim.width = slot_side;
-                                                    scaled_dim.height = slot_side;
-                                                    pos.x = slot_bounds.left;
-                                                    pos.y = slot_bounds.top;
-                                                }
-                                                return {
-                                                    ref: obj,
-                                                    position: pos,
-                                                    dimensions: scaled_dim,
-                                                };
-                                            } else {
-                                                return undefined;
-                                            }
-                                        },
-                                        bounding_zone: slot_bounds,
-                                        get_children: () => [
-                                            new ClosingIconCpt({
-                                                is_visible: scene_is_not_cinematic,
-                                                color: "#bf5e43", // Some kind of red
-                                                center: {
-                                                    x: slot_bounds.left + (slot_side / 2),
-                                                    y: slot_bounds.top + (slot_side / 2)
-                                                },
-                                                radius: slot_side / 4,
-                                                line_width: slot_side / 20 > 2 ? slot_side / 20 : 2,
-                                                bounding_zone: { ...slot_bounds, clear_color: "rgba(0,0,0,0.2)" },
-                                            }),
-                                        ]
-                                    });
-
-                                    inventory_object.add_event_listener({
-                                        event_type: "mousemove",
-                                        listener: e => {
-                                            const obj = inventory_object.params.get_game_object(inventory_object.params.slot_index);
-                                            const bounds = inventory_object.params.bounding_zone;
-
-                                            inventory_object.state.draw_inventory_close_icon = inventory_object.params.is_visible()
-                                                && !!obj
-                                                && get_scene().game_objects.includes(obj.ref)
-                                                && (
-                                                    e.offsetX >= bounds.left
-                                                    && e.offsetX <= bounds.right
-                                                    && e.offsetY >= bounds.top
-                                                    && e.offsetY <= bounds.bottom
-                                                );
-                                        }
-                                    });
-
-                                    inventory_object.children[0].add_event_listener({
-                                        event_type: "click",
-                                        listener: e => {
-                                            if (!inventory_object.params.is_visible()) return false;
-                                            const obj = inventory_object.params.get_game_object(inventory_object.params.slot_index);
-                                            const bounds = inventory_object.params.bounding_zone;
-                                            const mouse_over_slot = e.offsetX >= bounds.left
-                                                && e.offsetX <= bounds.right
-                                                && e.offsetY >= bounds.top
-                                                && e.offsetY <= bounds.bottom;
-                                            if (!!obj && mouse_over_slot && get_scene().game_objects.includes(obj.ref)) {
-                                                this.params.on_drop_inventory_object(obj.ref);
-                                                this.clear_event_listeners();
-                                                this.clear_children({ exclude: "TextBoxCpt" });
-                                            }
-                                        }
-                                    });
-
-                                    return [inventory_object];
-                                },
-                            };
-
-                            const slot = new InventorySlotCpt(slot_params);
-                            slots.push(slot);
-                            slot_i++;
-                        });
-                    });
-                    return slots;
-                },
-            }),
-            choices_panel: new ChoicesPanelCpt({
-                bounding_zone: this.canvas_zones.choices_panel,
-                minimum_bounding_zone: () => {
-                    const initial_zone = this.canvas_zones.choices_panel;
-                    const minium_height = this.canvas_dimensions.choices_panel_metrics
-                        .get_minimum_panel_height_for_scene(
-                            get_scene_index()
-                        );
-                    const maximum_height = this.canvas_dimensions.choices_panel_metrics
-                        .get_total_height();
-                    const dif = maximum_height - minium_height;
-
-                    return Object.assign({ ...initial_zone }, {
-                        bottom: initial_zone.bottom - dif,
-                        height: initial_zone.height - dif,
-                    });
-                },
-                is_visible: scene_is_not_cinematic,
-                invisible_clear_color: get_game_settings("general").background_color,
-                get_children: () => Array.from({
-                    length: this.canvas_dimensions.choices_panel_metrics
-                        .get_max_choices_row_per_scene(get_scene_index()) * 2
-                }
-                ).map((_, i) => {
-                    const choices_settings = this.get_scaled_choices_panel_settings();
-                    const writable_parent_zone = { // The writable zone of the choices panel. = The choices_panel bounding box excluding the padding.
-                        left: this.canvas_zones.choices_panel.left + choices_settings.container_padding,
-                        right: this.canvas_zones.choices_panel.right - choices_settings.container_padding,
-                        top: this.canvas_zones.choices_panel.top + choices_settings.container_padding,
-                        bottom: this.canvas_zones.choices_panel.bottom - choices_settings.container_padding,
-                        width: 0,
-                        height: 0,
-                    };
-
-                    writable_parent_zone.width = writable_parent_zone.right - writable_parent_zone.left;
-                    writable_parent_zone.height = writable_parent_zone.bottom - writable_parent_zone.top;
-
-                    const choice_width = writable_parent_zone.width / 2;
-
-                    const { choices_panel_metrics } = this.canvas_dimensions;
-                    const choices_height_per_row = choices_panel_metrics.rows.map(row => row.text_height + row.padding_height);
-
-                    const choice_bounds = {
-                        left: writable_parent_zone.left + ((i % 2) * choice_width), // if i % 2 choice is on the right side of the panel
-                        top: writable_parent_zone.top + ((i > 1 ? 1 : 0) * choices_height_per_row[0]), // If index is > 1, choice is on the lower row
-                        right: writable_parent_zone.left + ((i % 2) * choice_width) + choice_width,
-                        bottom: writable_parent_zone.top + ((i > 1 ? 1 : 0) * choices_height_per_row[1]) + choices_height_per_row[i > 1 ? 1 : 0],
-                        width: choice_width,
-                        height: choices_height_per_row[i > 1 ? 1 : 0],
-                    };
-
-                    const choice_cpt = new ChoiceCpt({
-                        is_visible: scene_is_not_cinematic,
-                        minimum_bounding_zone: () => {
-                            if (!choice_cpt.minimum_bounding_zone) {
-                                const row_index = i > 1 ? 1 : 0;
-                                const metric = this.canvas_dimensions.choices_panel_metrics.get_one_choices_row_metrics(
-                                    get_scene_index(), row_index,
-                                );
-
-                                const min_height = metric.text_height + metric.padding_height;
-
-                                const dif_upper_row = (() => {
-                                    const choices_cpt = this.components.choices_panel.children;
-                                    const cpt_index = choices_cpt.indexOf(choice_cpt);
-                                    return cpt_index >= 2
-                                        ? choice_bounds.top - choices_cpt[0].params.minimum_bounding_zone().bottom
-                                        : 0;
-                                })();
-
-                                choice_cpt.minimum_bounding_zone = Object.assign({ ...choice_bounds }, {
-                                    height: min_height,
-                                    top: choice_bounds.top - dif_upper_row,
-                                    bottom: choice_bounds.top - dif_upper_row + min_height,
-                                });
-                            }
-                            return choice_cpt.minimum_bounding_zone;
-                        },
-                        get_formatted_choice: () => {
-                            const scene_choices = this.canvas_dimensions.choices_panel_metrics.scenes_formatted_choices[
-                                get_game_scenes().indexOf(get_scene())
-                            ];
-                            return scene_choices.length - 1 >= i ? scene_choices[i] : undefined;
-                        },
-                        settings: choices_settings,
-                    });
-
-                    choice_cpt.add_event_listener({
-                        event_type: "mousemove",
-                        listener: e => {
-                            choice_cpt.state.active =
-                                choice_cpt.params.is_visible()
-                                && !!choice_cpt.params.get_formatted_choice()
-                                && choice_cpt.is_hover(e);
-                        }
-                    });
-
-                    choice_cpt.add_event_listener({
-                        event_type: "click",
-                        listener: () => {
-                            if (choice_cpt.state.active) {
-                                const choice = choice_cpt.params.get_formatted_choice();
-                                const success = this.params.on_choice_click(choice);
-                                if (success) {
-                                    this.clear_event_listeners();
-                                    this.clear_children();
-                                }
-                            }
-                        }
-                    });
-
-                    return choice_cpt;
-                })
-            }),
-        };
-    }
-
-    /**
-     * Draw a loading state on the black screen while some game resources are loading.
-     * This shouldn't show up a lot except if some resources are really big...
-     */
-    draw_loading() {
-        const ctx = window.mentalo_drawing_context;
-        ctx.save();
-        ctx.font = '25px monospace';
-        ctx.fillStyle = "black";
-        ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
-        ctx.fillStyle = "white";
-        ctx.fillText("Loading", 50, window.innerHeight / 2);
-        const dots = Array.from({ length: ++this.loading_frame }).map(() => '.').join('');
-        ctx.font = '8px monospace';
-        ctx.fillText(dots, 50, window.innerHeight / 2 + 20);
-        ctx.restore();
-    }
-
-    /**
-     * Executes the draw method of each registered component.
-     */
-    draw_game() {
-        Object.values(this.components).forEach(cpt => cpt.draw());
-    }
-}
-
-module.exports = MtlRender;
-},{"../lib/color-tools":6,"../lib/font-tools":7,"../lib/frame-rate-controller":8,"../model/scene-types":18,"./canvas-dimensions":21,"./ui-components/choice-cpt":24,"./ui-components/choices-panel-cpt":25,"./ui-components/closing-icon-cpt":26,"./ui-components/game-object-cpt":27,"./ui-components/inventory-cpt":28,"./ui-components/inventory-object-cpt":29,"./ui-components/inventory-slot-cpt":30,"./ui-components/scene-animation-cpt":31,"./ui-components/text-box-cpt":32,"./ui-components/user-error-popup":34}],24:[function(require,module,exports){
-"use strict";
-
-const { draw_text_in_bounds } = require("../../lib/text-tools");
-const { draw_rect } = require("../../lib/shape-tools");
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The rendering component for a scene choice.
- */
-class ChoiceCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("ChoiceCpt")
-    }
-
-    /**
-     * Draw the choice on the registered 2D drawing context
-     */
-    draw() {
-        super.draw();
-        const {
-            get_formatted_choice,
-            settings,
-        } = this.params;
-
-        const bounding_zone = this.params.minimum_bounding_zone();
-
-        const writable_zone = {
-            left: bounding_zone.left + settings.choice_padding,
-            right: bounding_zone.right - settings.choice_padding,
-            top: bounding_zone.top + settings.choice_padding,
-            bottom: bounding_zone.bottom - settings.choice_padding,
-            width: bounding_zone.width - (2 * settings.choice_padding),
-            height: bounding_zone.height - (2 * settings.choice_padding),
-        };
-
-        const choice = get_formatted_choice();
-
-        const ctx = window.mentalo_drawing_context;
-
-        if (choice) {
-            if (this.state.active) {
-                const { left, top, width, height } = bounding_zone;
-
-                draw_rect(ctx, left, top, width, height, {
-                    fill_color: settings.active_choice_background_color,
-                    rounded_corners_radius: settings.active_choice_rounded_corners_radius,
-                    border: {
-                        width: settings.active_choice_border_width,
-                        color: settings.font_color,
-                    }
-                });
-            }
-
-            draw_text_in_bounds(ctx, choice.text_lines, writable_zone, settings, {}, false);
-        }
-    }
-
-    /**
-     * @param {Event} e 
-     * @returns {Boolean} true if the mouse event is inside the choice bounding box
-     */
-    is_hover(e) {
-        const bounds = this.params.minimum_bounding_zone();
-        return e.offsetX >= bounds.left
-            && e.offsetX <= bounds.right
-            && e.offsetY >= bounds.top
-            && e.offsetY <= bounds.bottom;
-    }
-}
-
-module.exports = ChoiceCpt;
-},{"../../lib/shape-tools":9,"../../lib/text-tools":10,"./ui-component":33}],25:[function(require,module,exports){
-"use strict";
-
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The rendering component for the game choices panel
- */
-class ChoicesPanelCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("ChoicesPanelCpt")
-    }
-
-    /**
-     * Draw the choices panel and the choices as children components on the registered 2D drawing context
-     */
-    draw() {
-        super.draw();
-        if (this.params.is_visible()) {
-            this.clear_bounding_zone({ clear_color: this.params.invisible_clear_color });
-            this.clear_bounding_zone({ use_bounding_zone: this.params.minimum_bounding_zone() })
-            this.draw_children();
-        } else {
-            this.clear_bounding_zone({ clear_color: this.params.invisible_clear_color })
-        }
-    }
-}
-
-module.exports = ChoicesPanelCpt;
-},{"./ui-component":33}],26:[function(require,module,exports){
-"use strict";
-
-const MtlUiComponent = require("./ui-component");
-
-/**
- * A rendering component that displays a closing icon (a cross inside a circle)
- */
-class ClosingIconCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("ClosingIconCpt")
-    }
-
-    /**
-     * Uses standard vectorial drawing methods to draw a circle and a cross at 
-     * given coordinates on the registered 2D drawing context.
-     */
-    draw() {
-        super.draw();
-        this.clear_bounding_zone();
-        const { color, center, radius = 5, line_width = 2, background_color = "rgba(0,0,0,0)" } = this.params;
-        const ctx = window.mentalo_drawing_context;
-        const padding = radius / 1.5;
-
-        const cross_coords = {
-            left: center.x - radius + padding,
-            right: center.x + radius - padding,
-            top: center.y - radius + padding,
-            bottom: center.y + radius - padding,
-        };
-
-        ctx.fillStyle = background_color;
-        ctx.beginPath();
-        ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI);
-        ctx.fill();
-
-        ctx.strokeStyle = color;
-        ctx.lineWidth = line_width;
-        ctx.beginPath();
-        ctx.arc(center.x, center.y, radius, 0, 2 * Math.PI);
-        ctx.stroke();
-
-        ctx.beginPath();
-        ctx.moveTo(cross_coords.left, cross_coords.top);
-        ctx.lineTo(cross_coords.right, cross_coords.bottom);
-        ctx.moveTo(cross_coords.left, cross_coords.bottom)
-        ctx.lineTo(cross_coords.right, cross_coords.top);
-        ctx.stroke();
-    }
-}
-
-module.exports = ClosingIconCpt;
-},{"./ui-component":33}],27:[function(require,module,exports){
-"use strict";
-
-const MtlUiComponent = require("./ui-component");
-
-/**
- * A component that draws a GameObject
- */
-class GameObjectCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("GameObjectCpt")
-    }
-
-    /**
-     * Draws the image of the object on the canvas.
-     */
-    draw() {
-        super.draw();
-        const { position, dimensions, image, is_in_inventory } = this.params;
-        if (is_in_inventory()) {
-            return;
-        }
-
-        const ctx = window.mentalo_drawing_context;
-
-        ctx.drawImage(
-            image,
-            0, 0,
-            image.width, image.height,
-            position.x, position.y,
-            dimensions.w, dimensions.h,
-        );
-
-        if (this.state.draw_border) {
-            ctx.lineWidth = 1;
-            ctx.strokeStyle = "rgba(180, 180, 180, 0.5)";
-            ctx.strokeRect(position.x - 5, position.y - 5, dimensions.w + 10, dimensions.h + 10);
-        }
-    }
-}
-
-module.exports = GameObjectCpt;
-},{"./ui-component":33}],28:[function(require,module,exports){
-"use strict";
-
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The component that draws the inventory panel
- */
-class InventoryCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("InventoryCpt")
-    }
-
-    /**
-     * Draws the panel on the canvas, T
-     * the grid slots and the game object images are draws as children components (InventorySlotCpt and InventoryObjectCpt)
-     */
-    draw() {
-        super.draw();
-        if (this.params.is_visible()) {
-            this.clear_bounding_zone();
-            this.draw_children();
-        } else {
-            this.clear_bounding_zone({ clear_color: this.params.invisible_clear_color })
-        }
-    }
-}
-
-module.exports = InventoryCpt;
-},{"./ui-component":33}],29:[function(require,module,exports){
-"use strict";
-
-const { draw_rect } = require("../../lib/shape-tools");
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The component to display a game object image inside a grid slot of the inventory panel.
- */
-class InventoryObjectCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("InventoryObjectCpt")
-    }
-
-    /**
-     * If a game object exists for the slot bound to this instance, its image will be drawn cropped inside a bounding rectangle.
-     * When a game object is hovered in the inventory, the draw_inventory_closing_icon is updated and a red cross will be drawn 
-     * on top of the object image. If the cross get's clicked the object is removed from inventory.
-     */
-    draw() {
-        super.draw();
-        const { get_game_object, slot_index, settings, bounding_zone, stroke_color } = this.params;
-        const game_object = get_game_object(slot_index);
-        if (game_object) {
-            const ctx = window.mentalo_drawing_context;
-
-            const image = game_object.ref.image;
-            draw_rect(ctx, bounding_zone.left, bounding_zone.top, bounding_zone.width, bounding_zone.height, {
-                fill_image: {
-                    src: image,
-                    dw: game_object.dimensions.width,
-                    dh: game_object.dimensions.height,
-                    dx: game_object.position.x,
-                    dy: game_object.position.y,
-                },
-                rounded_corners_radius: settings.slot_rounded_corner_radius,
-                border: {
-                    width: settings.slot_border_width,
-                    color: stroke_color,
-                },
-            });
-
-            if (this.state.draw_inventory_close_icon) {
-                this.draw_children(); // drop object icon
-            }
-        }
-    }
-}
-
-module.exports = InventoryObjectCpt;
-},{"../../lib/shape-tools":9,"./ui-component":33}],30:[function(require,module,exports){
-"use strict";
-
-const { draw_rect } = require("../../lib/shape-tools");
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The component to draw a grid slot in the inventory panel.
- */
-class InventorySlotCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("InventorySlotCpt");
-    }
-
-    /**
-     * Draws a rectangle as the grid slot.
-     * Calls draw_children to draw the image of the game object stored in that slot if there is one.
-     */
-    draw() {
-        super.draw();
-        const { bounding_zone, stroke_color, settings } = this.params;
-        const { left, top, width, height } = bounding_zone;
-        const ctx = window.mentalo_drawing_context;
-
-        draw_rect(ctx, left, top, width, height, {
-            fill_color: "rgba(0,0,0,0)",
-            rounded_corners_radius: settings.slot_rounded_corner_radius,
-            border: {
-                width: settings.slot_border_width,
-                color: stroke_color,
-            }
-        });
-
-        this.draw_children();
-    }
-}
-
-module.exports = InventorySlotCpt;
-},{"../../lib/shape-tools":9,"./ui-component":33}],31:[function(require,module,exports){
-"use strict";
-
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The component that draws the scene animation.
- */
-class SceneAnimationCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("SceneAnimationCpt");
-        this.framecount = 0;
-        // This state controls the visibility of the text box child component.
-        this.state.text_box_visible = true;
-    }
-
-    /**
-     * Sets the text_box_visibility state.
-     * @param {Boolean} value 
-     */
-    set_text_box_visibility(value) {
-        this.state.text_box_visible = value;
-    }
-
-    /**
-     * Increments the animation frame count
-     */
-    update_framecount() {
-        this.framecount = this.framecount + 1 <= Number.MAX_SAFE_INTEGER ? this.framecount + 1 : 0;
-    }
-
-    /**
-     * Draw the scene animation on the canvas  at the frame given by the animation state
-     * and draw each child component like text box, game objects and error popup.
-     */
-    draw() {
-        super.draw();
-        const { bounding_zone, get_animation, next_frame_ready } = this.params;
-        if (next_frame_ready()) {
-            this.clear_bounding_zone();
-            const ctx = window.mentalo_drawing_context;
-
-            const animation = get_animation();
-            animation.update_frame(this.framecount);
-            const dim = animation.dimensions;
-            const w = dim.width;
-            const h = dim.height;
-            const offsetX = animation.frame * w;
-
-            const cw = bounding_zone.width;
-            const ch = bounding_zone.height;
-
-            // center the image
-            if (!animation.canvas_precalc) {
-                animation.canvas_precalc = {
-                    dx: bounding_zone.left,
-                    dy: bounding_zone.top,
-                    dw: cw,
-                    dh: ch,
-                };
-
-                if (w / h > cw / ch) {
-                    // image is more panoramic than canvas
-                    animation.canvas_precalc.dw = cw;
-                    animation.canvas_precalc.dh = cw * (h / w);
-                    // center vertically
-                    animation.canvas_precalc.dy = bounding_zone.top + ((ch - animation.canvas_precalc.dh) / 2);
-                } else if (cw / ch > w / h) {
-                    // canvas is more panoramic
-                    animation.canvas_precalc.dh = ch;
-                    animation.canvas_precalc.dw = ch * (w / h);
-                    // center horizontally
-                    animation.canvas_precalc.dx = bounding_zone.left + ((cw - animation.canvas_precalc.dw) / 2);
-                } else {
-                    // same ratio
-                    animation.canvas_precalc.dh = ch;
-                    animation.canvas_precalc.dw = cw;
-                }
-            }
-
-            const { dx, dy, dw, dh } = animation.canvas_precalc;
-            ctx.drawImage(
-                animation.image,
-                offsetX, 0,
-                w, h,
-                dx, dy,
-                dw, dh
-            );
-
-            this.draw_children();
-            this.update_framecount();
-        }
-    }
-}
-
-module.exports = SceneAnimationCpt;
-},{"./ui-component":33}],32:[function(require,module,exports){
-"use strict";
-
-const { draw_text_in_bounds } = require("../../lib/text-tools");
-const { get_canvas_char_size } = require("../../lib/font-tools");
-const { draw_rect } = require("../../lib/shape-tools");
-const MtlUiComponent = require("./ui-component");
-
-/**
- * The component that handles the displaying of a scene text boxe.
- */
-class TextBoxCpt extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.set_str_id("TextBoxCpt");
-        this.state.text = {};
-    }
-
-    /**
-     * Initialize precalculations for the text box, text dimensions, split lines, etc.
-     */
-    init() {
-        const { text, settings, bounding_zone } = this.params;
-
-        const char_size = get_canvas_char_size(window.mentalo_drawing_context, settings);
-
-        const line_height = char_size.text_line_height;
-        const error_offset = 3 * char_size.width;
-        const chars_per_line = (bounding_zone.width - error_offset) / char_size.width;
-        const output_lines = [];
-        const lines = text.split("\n");
-
-        lines.forEach(line => {
-            const res = [""];
-            let target_i = 0;
-            for (const word of line.split(" ")) {
-                if ((res[target_i] + word + " ").length > chars_per_line) {
-                    res.push("");
-                    target_i++;
-                }
-                res[target_i] += word + " ";
-            }
-
-            res.forEach(l => output_lines.push(l));
-        });
-
-        const text_height = output_lines.length * line_height - char_size.interline_height;
-
-        const updated_bounds = Object.assign({ ...bounding_zone }, {
-            height: bounding_zone.height + text_height,
-            top: bounding_zone.top - text_height,
-        });
-
-        this.state.text = {
-            lines: output_lines,
-            line_height,
-            bounding_zone: updated_bounds,
-        };
-
-        // Update closing icon position
-        const closing_icon = this.children[0];
-        let closing_icon_bounds = closing_icon.params.bounding_zone;
-        closing_icon_bounds = Object.assign(closing_icon_bounds, {
-            top: closing_icon_bounds.top - text_height,
-            bottom: closing_icon_bounds.bottom - text_height,
-        });
-
-        closing_icon.params.center.y -= text_height;
-
-        this.state.initialized = true;
-    }
-
-    /**
-     * Draw the text box and the text inside of it.
-     * The text is not drawn at once, the characters are drawn one by one at each frame 
-     * and a local text.stream state is updated until text is complete.
-     */
-    draw() {
-        if (!this.params.get_visibility_state()) {
-            return;
-        }
-
-        super.draw();
-
-        const { settings } = this.params;
-
-        if (!this.state.initialized) {
-            this.init();
-        }
-
-        const ctx = window.mentalo_drawing_context;
-        const { lines, bounding_zone } = this.state.text;
-
-        const box_pos = { x: bounding_zone.left, y: bounding_zone.top };
-        const box_width = bounding_zone.width;
-        const box_height = bounding_zone.height;
-
-        draw_rect(ctx, box_pos.x, box_pos.y, box_width, box_height, {
-            fill_color: settings.background_color,
-            rounded_corners_radius: settings.rounded_corners_radius,
-            border: {
-                width: settings.border_width,
-                color: settings.font_color,
-            },
-        });
-
-        const complete = draw_text_in_bounds(ctx, lines, bounding_zone, settings, this.state.text, true);
-
-        if (complete) { // draw closing_icon if text is entirely displayed
-            this.draw_children();
-        }
-    }
-}
-
-module.exports = TextBoxCpt;
-},{"../../lib/font-tools":7,"../../lib/shape-tools":9,"../../lib/text-tools":10,"./ui-component":33}],33:[function(require,module,exports){
-"use strict";
-
-const { draw_rect } = require("../../lib/shape-tools");
-
-// const { draw_rect } = require("../lib/shape-tools");
-
-/**
- * A generic class that must be extended by all components registered in MtlRender.components.
- * It provides helping methods to handle event listeners and children components.
- */
-class MtlUiComponent {
-    constructor(params) {
-        this.set_str_id();
-        this.params = params;
-        this.params.event_listeners = this.params.event_listeners || [];
-        this.params.get_children = this.params.get_children || (() => []);
-        this.params.is_visible = this.params.is_visible || (() => true);
-        this.children = [];
-        this.children_set = false;
-        this.set_children();
-        this.state = {
-            event_listeners_initialized: false,
-        };
-
-        this.init_event_listeners();
-    }
-
-    /**
-     * Provide a identifier for the object. Default is contructor name.
-     * Should be called from child class to set a custom id.
-     * @param {String} str 
-     */
-    set_str_id(str = this.constructor.name) {
-        this.str_id = str;
-    }
-
-    /**
-     * Draws a black rectangle on all the surface of the bounding zone defined for this component
-     * @param {Object} options can provide a clear_color<String> rgba value
-     */
-    clear_bounding_zone(options = {}) {
-        let { bounding_zone } = this.params;
-        const { use_bounding_zone } = options;
-        bounding_zone = use_bounding_zone || bounding_zone;
-        const { left, top, width, height } = bounding_zone;
-        draw_rect(
-            window.mentalo_drawing_context,
-            left, top, width, height,
-            {
-                fill_color: options.clear_color || bounding_zone.clear_color || "rgba(0,0,0,0)"
-            }
-        );
-    }
-
-    /**
-     * Removes the event listeners attached to this component and to children components.
-     * @param {Object} options can pass a recursive flag wether children must be cleared or not. Default is true.
-     */
-    clear_event_listeners(options = { recursive: true }) {
-        this.params.event_listeners.forEach(ref => {
-            window.removeEventListener(ref.event_type, ref.listener);
-        });
-
-        if (options.recursive) {
-            this.children.forEach(child => {
-                child.clear_event_listeners();
-            });
-        }
-
-        this.state.event_listeners_initialized = false;
-    }
-
-    /**
-     * Removes children components from this component.
-     * Options can define some component to exlude identifying them by their str_id.
-     * The excluded component will be kept as persistent components.
-     * @param {Object} options 
-     */
-    clear_children(options) {
-        const { exclude } = options;
-        const persistent_children = [];
-
-        this.children.forEach(child => {
-            if (exclude.includes(child.str_id)) {
-                persistent_children.push(child);
-            }
-        });
-
-        this.children = persistent_children;
-        this.children_set = false;
-    }
-
-    /**
-     * Register an array of children components in this component.
-     * This is called when chlidren_set is false. Which happend at initialization and when clear_children is called.
-     * Doing this allows saving the get_children() calculation at each frame.
-     * This children components are concatenated recursively with their own children components.
-     */
-    set_children() {
-        this.children = this.children.concat(
-            this.params.get_children()
-                .filter(child => {
-                    // If this child has been kept as a persistent component, keep it as it.
-                    const keep_previous_child = this.children.find(ch => ch.str_id === child.str_id);
-                    return !keep_previous_child;
-                })
-        );
-
-        this.children_set = true;
-    }
-
-    /**
-     * Attaches a event listener to this component
-     * @param {Object} obj A descriptor of the event listener like :
-     * {
-     *      event_type: "click",
-     *      listener: e => {
-     *          ... something to do on click that component
-     *      }
-     * }
-     */
-    add_event_listener(obj) {
-        const len = this.params.event_listeners.push(obj);
-        const ref = this.params.event_listeners[len - 1];
-        window.addEventListener(ref.event_type, ref.listener);
-    }
-
-    /**
-     * Attaches the event listeners given as parameter to the component
-     */
-    init_event_listeners() {
-        const { event_listeners } = this.params;
-        event_listeners.forEach(obj => this.add_event_listener(obj));
-        this.state.event_listeners_initialized = true
-    }
-
-    /**
-     * Call the draw method of the children component
-     */
-    draw_children() {
-        if (!this.children_set) {
-            this.set_children();
-        }
-        this.children.forEach(c => c.draw());
-    }
-
-    /**
-     * Initializes the event listeners if it's not already done.
-     * This must be called by the inherited call draw method.
-     */
-    draw() {
-        if (!this.state.event_listeners_initialized) {
-            this.init_event_listeners();
-        }
-    }
-}
-
-module.exports = MtlUiComponent;
-},{"../../lib/shape-tools":9}],34:[function(require,module,exports){
-"use strict";
-
-const { draw_text_in_bounds } = require("../../lib/text-tools");
-const { draw_rect } = require("../../lib/shape-tools");
-const MtlUiComponent = require("./ui-component");
-
-/**
- * A component to display an error message in a popup
- */
-class UserErrorPopup extends MtlUiComponent {
-    constructor(params) {
-        super(params);
-        this.state = {
-            text: {},
-        };
-
-        this.set_str_id("UserErrorPopup");
-    }
-
-    /**
-     * Draw the popup to the canvas.
-     * Draws the box, then draw the text lines.
-     * draw_children is for the closing_icon.
-     */
-    draw() {
-        super.draw();
-        const { text_lines, bounding_zone, padding, settings, modal_bounds, clear_modal_color } = this.params;
-        this.clear_bounding_zone({ use_bounding_zone: modal_bounds, clear_color: clear_modal_color });
-
-        const ctx = window.mentalo_drawing_context;
-        const { left, top, width, height } = bounding_zone;
-
-        draw_rect(ctx, left, top, width, height, {
-            fill_color: settings.background_color,
-            rounded_corners_radius: settings.rounded_corners_radius,
-            border: {
-                width: settings.border_width,
-                color: settings.font_color,
-            },
-        });
-
-        const complete = draw_text_in_bounds(
-            ctx,
-            text_lines,
-            bounding_zone,
-            Object.assign({ ...settings }, padding),
-            this.state.text,
-            !!this.params.stream_text
-        );
-
-        if (complete) {
-            this.draw_children();
-        }
-    }
-}
-
-module.exports = UserErrorPopup;
-},{"../../lib/shape-tools":9,"../../lib/text-tools":10,"./ui-component":33}],35:[function(require,module,exports){
-const supported_locales = ["en", "fr", "es"];
-
-/**
- * Translations for the default error messages.
- */
-
-const translations = {
-    "Some scenes have an empty image, game cannot be loaded": {
-        fr: "Certaines scènes ont une image vide, le jeu ne peut pas être chargé",
-        es: "Algunas escenas tienen una imagen vacía, el juego no se puede cargar"
-    },
-    "Destination scene has not been set.": {
-        fr: "La scène de destination n'a pas été définie.",
-        es: "La escena de destino no ha sido definida.",
-    },
-    "Next scene has not been set.": {
-        fr: "La scène suivante n'a pas été définie",
-        es: "La siguiente escena no ha sido definida.",
-    },
-};
-
-function get_translated(str, locale) {
-    return translations[str] && locale !== "en" && supported_locales.includes(locale)
-        ? translations[str][locale]
-        : str;
-}
-
-module.exports = {
-    get_translated,
-    supported_locales,
-};
-},{}],36:[function(require,module,exports){
-"use strict";
-
-module.exports = {
-    register_key: "objectToHtmlRender",
-
-    /**
-     * Register "this" as a window scope accessible variable named by the given key, or default.
-     * @param {String} key 
-     */
-    register(key) {
-        const register_key = key || this.register_key;
-        window[register_key] = this;
-    },
-
-    /**
-     * This must be called before any other method in order to initialize the lib.
-     * It provides the root of the rendering cycle as a Javascript object.
-     * @param {Object} renderCycleRoot A JS component with a render method.
-     */
-    setRenderCycleRoot(renderCycleRoot) {
-        this.renderCycleRoot = renderCycleRoot;
-    },
-
-    event_name: "objtohtml-render-cycle",
-
-    /**
-     * Set a custom event name for the event that is trigger on render cycle.
-     * Default is "objtohtml-render-cycle".
-     * @param {String} evt_name 
-     */
-    setEventName(evt_name) {
-        this.event_name = evt_name;
-    },
-
-    /**
-     * This is the core agorithm that read an javascript Object and convert it into an HTML element.
-     * @param {Object} obj The object representing the html element must be formatted like:
-     * {
-     *      tag: String // The name of the html tag, Any valid html tag should work. div, section, br, ul, li...
-     *      xmlns: String // This can replace the tag key if the element is an element with a namespace URI, for example an <svg> tag.
-     *                      See https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS for more information
-     *      style_rules: Object // a object providing css attributes. The attributes names must be in JS syntax,
-     *                              like maxHeight: "500px", backgrouncColor: "#ff2d56",  margin: 0,  etc.
-     *      contents: Array or String // This reprensents the contents that will be nested in the created html element.
-     *                                   <div>{contents}</div>
-     *                                   The contents can be an array of other objects reprenting elements (with tag, contents, etc)
-     *                                   or it can be a simple string.
-     *      // All other attributes will be parsed as html attributes. They can be anything like onclick, href, onchange, title...
-     *      // or they can also define custom html5 attributes, like data, my_custom_attr or anything.
-     * }
-     * @returns {HTMLElement} The output html node.
-     */
-    objectToHtml(obj) {
-        if (!obj) return document.createElement("span"); // in case of invalid input, don't block the whole process.
-        const objectToHtml = this.objectToHtml.bind(this);
-        const { tag, xmlns } = obj;
-        const node = xmlns !== undefined ? document.createElementNS(xmlns, tag) : document.createElement(tag);
-        const excludeKeys = ["tag", "contents", "style_rules", "state", "xmlns"];
-
-        Object.keys(obj)
-            .filter(attr => !excludeKeys.includes(attr))
-            .forEach(attr => {
-                switch (attr) {
-                    case "class":
-                        node.classList.add(...obj[attr].split(" ").filter(s => s !== ""));
-                        break;
-                    case "on_render":
-                        if (!obj.id) {
-                            node.id = `${btoa(JSON.stringify(obj).slice(0, 127)).replace(/\=/g, '')}${window.performance.now()}`;
-                        }
-                        if (typeof obj.on_render !== "function") {
-                            console.error("The on_render attribute must be a function")
-                        } else {
-                            this.attach_on_render_callback(node, obj.on_render);
-                        }
-                        break;
-                    default:
-                        if (xmlns !== undefined) {
-                            node.setAttributeNS(null, attr, obj[attr])
-                        } else {
-                            node[attr] = obj[attr];
-                        }
-                }
-            });
-        if (obj.contents && typeof obj.contents === "string") {
-            node.innerHTML = obj.contents;
-        } else {
-            obj.contents &&
-                obj.contents.length > 0 &&
-                obj.contents.forEach(el => {
-                    switch (typeof el) {
-                        case "string":
-                            node.innerHTML = el;
-                            break;
-                        case "object":
-                            if (xmlns !== undefined) {
-                                el = Object.assign(el, { xmlns })
-                            }
-                            node.appendChild(objectToHtml(el));
-                            break;
-                    }
-                });
-        }
-
-        if (obj.style_rules) {
-            Object.keys(obj.style_rules).forEach(rule => {
-                node.style[rule] = obj.style_rules[rule];
-            });
-        }
-
-        return node;
-    },
-
-    on_render_callbacks: [],
-
-    /**
-     * This is called if the on_render attribute of a component is set.
-     * @param {HTMLElement} node The created html element
-     * @param {Function} callback The callback defined in the js component to render
-     */
-    attach_on_render_callback(node, callback) {
-        const callback_handler = {
-            callback: e => {
-                if (e.detail.outputNode === node || e.detail.outputNode.querySelector(`#${node.id}`)) {
-                    callback(node);
-                    const handler_index = this.on_render_callbacks.indexOf((this.on_render_callbacks.find(cb => cb.node === node)));
-                    if (handler_index === -1) {
-                        console.warn("A callback was registered for node with id " + node.id + " but callbacck handler is undefined.")
-                    } else {
-                        window.removeEventListener(this.event_name, this.on_render_callbacks[handler_index].callback)
-                        this.on_render_callbacks.splice(handler_index, 1);
-                    }
-                }
-            },
-            node,
-        };
-
-        const len = this.on_render_callbacks.push(callback_handler);
-        window.addEventListener(this.event_name, this.on_render_callbacks[len - 1].callback);
-    },
-
-    /**
-     * If a main element exists in the html document, it will be used as rendering root.
-     * If not, it will be created and inserted.
-     */
-    renderCycle: function () {
-        const main_elmt = document.getElementsByTagName("main")[0] || (function () {
-            const created_main = document.createElement("main");
-            document.body.appendChild(created_main);
-            return created_main;
-        })();
-
-        this.subRender(this.renderCycleRoot.render(), main_elmt, { mode: "replace" });
-    },
-
-    /**
-     * This method behaves like the renderCycle() method, but rather that starting the rendering cycle from the root component,
-    * it can start from any component of the tree. The root component must be given as the first argument, the second argument be
-    * be a valid html element in the dom and will be used as the insertion target.
-     * @param {Object} object An object providing a render method returning an object representation of the html to insert
-     * @param {HTMLElement} htmlNode The htlm element to update
-     * @param {Object} options can be used the define the insertion mode, default is set to "append" and can be set to "override",
-         * "insert-before" (must be defined along with an insertIndex key (integer)),
-         * "adjacent" (must be defined along with an insertLocation key (String)), "replace" or "remove".
-         * In case of "remove", the first argument "object" is not used and can be set to null, undefined or {}...
-     */
-    subRender(object, htmlNode, options = { mode: "append" }) {
-        let outputNode = null;
-
-        const get_insert = () => {
-            outputNode = this.objectToHtml(object);
-            return outputNode;
-        };
-
-        switch (options.mode) {
-            case "append":
-                htmlNode.appendChild(get_insert());
-                break;
-            case "override":
-                htmlNode.innerHTML = "";
-                htmlNode.appendChild(get_insert());
-                break;
-            case "insert-before":
-                htmlNode.insertBefore(get_insert(), htmlNode.childNodes[options.insertIndex]);
-                break;
-            case "adjacent":
-                /**
-                 * options.insertLocation must be one of:
-                 *
-                 * afterbegin
-                 * afterend
-                 * beforebegin
-                 * beforeend
-                 */
-                htmlNode.insertAdjacentHTML(options.insertLocation, get_insert());
-                break;
-            case "replace":
-                htmlNode.parentNode.replaceChild(get_insert(), htmlNode);
-                break;
-            case "remove":
-                htmlNode.remove();
-                break;
-        }
-        const evt_name = this.event_name;
-        const event = new CustomEvent(evt_name, {
-            detail: {
-                inputObject: object,
-                outputNode,
-                insertOptions: options,
-                targetNode: htmlNode,
-            }
-        });
-
-        window.dispatchEvent(event);
-    },
-};
-},{}],37:[function(require,module,exports){
-"use strict";
-
-class ImageCarousel {
-    constructor(props) {
-        this.props = props;
-        this.id = this.props.images.join("").replace(/\s\./g);
-        this.state = {
-            showImageIndex: 0,
-        };
-        this.RUN_INTERVAL = 5000;
-        this.props.images.length > 1 && this.run();
-    }
-
-    run() {
-        this.runningInterval = setInterval(() => {
-            let { showImageIndex } = this.state;
-            const { images } = this.props;
-            this.state.showImageIndex = showImageIndex < images.length - 1 ? ++showImageIndex : 0;
-            this.refreshImage();
-        }, this.RUN_INTERVAL);
-    }
-
-    setImageIndex(i) {
-        clearInterval(this.runningInterval);
-        this.state.showImageIndex = i;
-        this.refreshImage();
-    }
-
-    refreshImage() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), {
-            mode: "replace",
-        });
-    }
-
-    render() {
-        const { showImageIndex } = this.state;
-        const { images } = this.props;
-        return {
-            tag: "div",
-            id: this.id,
-            class: "image-carousel",
-            contents: [
-                {
-                    tag: "img",
-                    property: "image",
-                    alt: `image carousel ${images[showImageIndex].replace(/\.[A-Za-z]+/, "")}`,
-                    src: images[showImageIndex],
-                },
-                images.length > 1 && {
-                    tag: "div",
-                    class: "carousel-bullets",
-                    contents: images.map((_, i) => {
-                        const active = showImageIndex === i;
-                        return {
-                            tag: "span",
-                            class: `bullet ${active ? "active" : ""}`,
-                            onclick: this.setImageIndex.bind(this, i),
-                        };
-                    }),
-                },
-            ],
-        };
-    }
-}
-
-module.exports = ImageCarousel;
-
-},{}],38:[function(require,module,exports){
-"use strict";
-
-const { fetch_json_or_error_text } = require("./fetch");
-
-function getArticleBody(text) {
-    return text.replaceAll("\n", "<br/>");
-}
-
-function getArticleDate(date) {
-    return `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}`;
-}
-
-function loadArticles(category, locale) {
-    return fetch_json_or_error_text(`/articles/${category}/${locale}`);
-}
-
-module.exports = {
-    loadArticles,
-    getArticleBody,
-    getArticleDate,
-};
-
-},{"./fetch":39}],39:[function(require,module,exports){
-"use strict";
-
-function fetchjson(url) {
-    return new Promise((resolve, reject) => {
-        fetch(url)
-            .then(r => r.json())
-            .then(r => resolve(r))
-            .catch(e => reject(e));
-    });
-}
-
-function fetchtext(url) {
-    return new Promise((resolve, reject) => {
-        fetch(url)
-            .then(r => r.text())
-            .then(r => resolve(r))
-            .catch(e => reject(e));
-    });
-}
-
-async function fetch_json_or_error_text(url, options = {}) {
-    return new Promise((resolve, reject) => {
-        fetch(url, options).then(async res => {
-            if (res.status >= 400 && res.status < 600) {
-                reject(await res.text());
-            } else {
-                resolve(await res.json());
-            }
-        })
-    })
-}
-
-module.exports = {
-    fetchjson,
-    fetchtext,
-    fetch_json_or_error_text,
-};
-
-},{}],40:[function(require,module,exports){
-"use strict";
-const translator = require("ks-cheap-translator");
-const { translations_url } = require("../../constants");
-
-class WebPage {
-    constructor(args) {
-        Object.assign(this, args);
-
-        if (!this.id) {
-            this.id = "webpage-" + performance.now();
-        }
-
-        translator.init({
-            translations_url,
-            supported_languages: ["fr", "en"],
-        }).then(this.refresh_all.bind(this));
-    }
-
-    refresh() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), { mode: "replace" })
-    }
-
-    refresh_all() {
-        obj2htm.renderCycle()
-    }
-}
-
-module.exports = WebPage;
-},{"../../constants":3,"ks-cheap-translator":4}],41:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../../../admin-frontend/src/constants");
-const { data_url } = require("../../../../constants");
-const ImageCarousel = require("../../../generic-components/image-carousel");
-const { getArticleBody } = require("../../../lib/article-utils");
-const { fetch_json_or_error_text } = require("../../../lib/fetch");
-const { MentaloEngine } = require("mentalo-engine");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-class GameArticle {
-    constructor(props) {
-        this.props = props;
-        this.parse_body();
-    }
-
-    parse_body() {
-        let body = getArticleBody(this.props.body);
-        const play_btn_regex = /\[PLAY_BUTTON\s\{.+\}\]/g;
-        const found_play_buttons = body.match(play_btn_regex);
-        if (found_play_buttons) {
-            this.build_play_button(JSON.parse(found_play_buttons[0].replace(/[\[\]PLAY_BUTTON\s]/g, "")));
-            body = body.replace(play_btn_regex, "");
-        }
-        this.body = body;
-    }
-
-    build_play_button(button_data) {
-        this.render_play_button = {
-            tag: "button",
-            class: "play-button",
-            contents: t("Jouer"),
-            onclick: this.handle_click_play.bind(this, button_data.filename, button_data.engine)
-        };
-    }
-
-    load_and_run_mentalo_game(filename, button_element) {
-        const button_text = button_element.innerHTML;
-        button_element.innerHTML = "Loading ...";
-        button_element.style.pointerEvents = "none";
-
-        fetch_json_or_error_text(`${data_url}/${filename}`)
-            .then(game_data => {
-                const container = document.createElement("div");
-                container.style.position = "fixed";
-                container.style.top = 0;
-                container.style.left = 0;
-                container.style.right = 0;
-                container.style.bottom = 0;
-                container.style.zIndex = 10;
-                container.style.display = "flex";
-                container.style.justifyContent = "center";
-                container.style.alignItems = "center";
-
-                container.id = "kuadrado-tmp-game-player-container";
-                document.body.appendChild(container);
-                document.body.style.overflow = "hidden";
-
-                const engine = new MentaloEngine({
-                    game_data,
-                    fullscreen: true,
-                    frame_rate: 30,
-                    container,
-                    on_quit_game: () => {
-                        container.remove();
-                        document.body.style.overflow = "visible";
-                    }
-                });
-
-                engine.init();
-                engine.run_game();
-            })
-            .catch(err => console.log(err))
-            .finally(() => {
-                button_element.innerHTML = button_text;
-                button_element.style.pointerEvents = "unset";
-            });
-    }
-
-    handle_click_play(filename, engine, e) {
-        switch (engine) {
-            case "mentalo":
-                this.load_and_run_mentalo_game(filename, e.target);
-                break;
-            default:
-                console.log("Error, unkown engine")
-                return;
-        }
-    }
-
-    render() {
-        const {
-            title,
-            subtitle,
-            images,
-            details,
-        } = this.props;
-
-        return {
-            tag: "article",
-            typeof: "VideoGame",
-            additionalType: "Article",
-            class: "game-article",
-            contents: [
-                {
-                    tag: "h2",
-                    property: "name",
-                    class: "game-title",
-                    contents: title,
-                },
-                {
-                    tag: "div",
-                    class: "game-banner",
-                    contents: [
-                        { tag: "img", class: "pixelated", src: `${images_url}/${images[0]}` },
-                    ],
-                },
-                {
-                    tag: "h3",
-                    class: "game-subtitle",
-                    contents: subtitle,
-                    property: "alternativeHeadline",
-                },
-                {
-                    tag: "div",
-                    class: "game-description",
-                    property: "description",
-                    contents: [{ tag: "p", style_rules: { margin: 0 }, contents: this.body }]
-                        .concat(this.render_play_button
-                            ? [this.render_play_button]
-                            : []),
-                },
-                new ImageCarousel({ images: images.map(img => `${images_url}/${img}`) }).render(),
-                details.length > 0 && {
-                    tag: "div",
-                    class: "article-details",
-                    contents: [
-                        {
-                            tag: "h2",
-                            contents: "Details",
-                        },
-                        {
-                            tag: "ul",
-                            class: "details-list",
-                            contents: details.map(detail => {
-                                return {
-                                    tag: "li",
-                                    class: "detail",
-                                    contents: [
-                                        { tag: "label", contents: detail.label },
-                                        {
-                                            tag: "div",
-                                            class: "detail-value",
-                                            contents: detail.value
-                                        },
-                                    ],
-                                };
-                            }),
-                        },
-                    ],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = GameArticle;
-
-},{"../../../../../admin-frontend/src/constants":1,"../../../../constants":3,"../../../generic-components/image-carousel":37,"../../../lib/article-utils":38,"../../../lib/fetch":39,"ks-cheap-translator":4,"mentalo-engine":5}],42:[function(require,module,exports){
-"use strict";
-
-const { loadArticles } = require("../../../lib/article-utils");
-const GameArticle = require("./game-article");
-const translator = require("ks-cheap-translator");
-
-class GameArticles {
-    constructor(props) {
-        this.props = props;
-        this.state = {
-            articles: [],
-        };
-        this.id = "game-articles-section";
-        this.loadArticles();
-    }
-
-    loadArticles() {
-        loadArticles("games", translator.locale)
-            .then(articles => {
-                this.state.articles = articles;
-                this.refresh();
-            })
-            .catch(e => console.log(e));
-    }
-
-    renderPlaceholder() {
-        return {
-            tag: "article",
-            class: "placeholder",
-            contents: [{ tag: "div" }, { tag: "div" }],
-        };
-    }
-
-    refresh() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), {
-            mode: "replace",
-        });
-    }
-
-    render() {
-        const { articles } = this.state;
-        return {
-            tag: "section",
-            class: "game-articles page-contents-center",
-            id: this.id,
-            contents:
-                articles.length > 0
-                    ? articles.map(article => new GameArticle({ ...article }).render())
-                    : [this.renderPlaceholder()],
-        };
-    }
-}
-
-module.exports = GameArticles;
-
-},{"../../../lib/article-utils":38,"./game-article":41,"ks-cheap-translator":4}],43:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const WebPage = require("../../lib/web-page");
-const GameArticles = require("./components/game-articles");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-class GamesPage extends WebPage {
-    render() {
-        return {
-            tag: "div",
-            id: "games-page",
-            contents: [
-                {
-                    tag: "div",
-                    class: "page-header logo-left",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "page-contents-center grid-wrapper",
-                            contents: [
-                                {
-                                    tag: "div",
-                                    class: "logo",
-                                    contents: [
-                                        {
-                                            tag: "img",
-                                            alt: "image game controller",
-                                            src: `${images_url}/game_controller.svg`,
-                                        },
-                                    ],
-                                },
-                                { tag: "h1", contents: t("Jeux") },
-                                {
-                                    tag: "p",
-                                    contents: t("games-page-intro"),
-                                },
-                            ],
-                        },
-                    ],
-                },
-                new GameArticles().render(),
-            ],
-        };
-    }
-}
-
-module.exports = GamesPage;
-
-},{"../../../constants":3,"../../lib/web-page":40,"./components/game-articles":42,"ks-cheap-translator":4}],44:[function(require,module,exports){
-"use strict";
-
-"use strict";
-const runPage = require("../../run-page");
-const GamesPage = require("./games");
-runPage(GamesPage);
-
-},{"../../run-page":45,"./games":43}],45:[function(require,module,exports){
-"use strict";
-
-const renderer = require("object-to-html-renderer")
-const Template = require("./template/template");
-
-module.exports = function runPage(PageComponent) {
-    const template = new Template({ page: new PageComponent() });
-    renderer.register("obj2htm")
-    obj2htm.setRenderCycleRoot(template);
-    obj2htm.renderCycle();
-};
-
-},{"./template/template":47,"object-to-html-renderer":36}],46:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-const NAV_MENU_ITEMS = [
-    { url: "/games/", text: "Jeux" },
-    {
-        url: "/education/",
-        text: "Pédagogie",
-    },
-    { url: "/software-development/", text: "Software" }
-];
-
-class NavBar {
-    constructor() {
-        this.initEventHandlers();
-    }
-
-    handleBurgerClick() {
-        document.getElementById("nav-menu-list").classList.toggle("responsive-show");
-    }
-
-    initEventHandlers() {
-        window.addEventListener("click", event => {
-            if (
-                event.target.id !== "nav-menu-list" &&
-                !event.target.classList.contains("burger") &&
-                !event.target.parentNode.classList.contains("burger")
-            ) {
-                document.getElementById("nav-menu-list").classList.remove("responsive-show");
-            }
-        });
-    }
-
-    handle_chang_lang(lang) {
-        translator.update_translations(lang).then(() => {
-            obj2htm.renderCycle();
-        }).catch(err => console.log(err));
-    }
-
-    renderHome() {
-        return {
-            tag: "div",
-            class: "home",
-            contents: [
-                {
-                    tag: "a",
-                    href: "/",
-                    contents: [
-                        {
-                            tag: "img",
-                            alt: "Logo Kuadrado",
-                            src: `${images_url}/logo_kuadrado.svg`,
-                        },
-                        {
-                            tag: "img",
-                            alt: "Kuadrado Software",
-                            class: "logo-text",
-                            src: `${images_url}/logo_kuadrado_txt.svg`,
-                        },
-                    ],
-                },
-            ],
-        };
-    }
-
-    renderMenu(menuItemsArray, isSubmenu = false, parentUrl = "") {
-        return {
-            tag: "ul",
-            id: "nav-menu-list",
-            class: isSubmenu ? "submenu" : "",
-            contents: menuItemsArray.map(item => {
-                const { url, text, submenu } = item;
-                const href = `${parentUrl}${url}`;
-                return {
-                    tag: "li",
-                    class: !isSubmenu && window.location.pathname === href ? "active" : "",
-                    contents: [
-                        {
-                            tag: "a",
-                            href,
-                            contents: t(text),
-                        },
-                    ].concat(submenu ? [this.renderMenu(submenu, true, url)] : []),
-                };
-            }).concat({
-                tag: "li",
-                class: "lang-flags",
-                contents: ["fr", "en"].map(lang => {
-                    return {
-                        tag: "img", src: `${images_url}/flag-${lang}.svg`,
-                        class: translator.locale === lang ? "selected" : "",
-                        onclick: this.handle_chang_lang.bind(this, lang)
-                    }
-                })
-            }),
-        };
-    }
-
-    renderResponsiveBurger() {
-        return {
-            tag: "div",
-            class: "burger",
-            onclick: this.handleBurgerClick.bind(this),
-            contents: [{ tag: "span", contents: "···" }],
-        };
-    }
-
-    render() {
-        return {
-            tag: "nav",
-            contents: [
-                this.renderHome(),
-                this.renderResponsiveBurger(),
-                this.renderMenu(NAV_MENU_ITEMS),
-            ],
-        };
-    }
-}
-
-module.exports = NavBar;
-
-},{"../../../constants":3,"ks-cheap-translator":4}],47:[function(require,module,exports){
-"use strict";
-
-const { in_construction } = require("../../config");
-const { images_url } = require("../../constants");
-const NavBar = require("./components/navbar");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator)
-
-class Template {
-    constructor(props) {
-        this.props = props;
-    }
-    render() {
-        return {
-            tag: "main",
-            contents: [
-                {
-                    tag: "header",
-                    contents: [new NavBar().render()],
-                },
-                in_construction && {
-                    tag: "section",
-                    class: "warning-banner",
-                    contents: [
-                        {
-                            tag: "strong",
-                            class: "page-contents-center",
-                            contents: t("Site en construction ..."),
-                        },
-                    ],
-                },
-                {
-                    tag: "section",
-                    id: "page-container",
-                    contents: [this.props.page.render()],
-                },
-                {
-                    tag: "footer",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "logo",
-                            contents: [
-                                {
-                                    tag: "img",
-                                    alt: `logo Kuadrado`,
-                                    src: `${images_url}/logo_kuadrado.svg`,
-                                },
-                                {
-                                    tag: "img",
-                                    class: "text-logo",
-                                    alt: "Kuadrado Software",
-                                    src: `${images_url}/logo_kuadrado_txt.svg`,
-                                },
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: "32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France",
-                        },
-                        {
-                            tag: "div",
-                            contents: [
-                                { tag: "strong", contents: "<blue>Contact : </blue>" },
-                                {
-                                    tag: "a",
-                                    href: "mailto:contact@kuadrado-software.fr",
-                                    contents: "contact@kuadrado-software.fr",
-                                },
-                            ],
-                        },
-                        {
-                            tag: "div",
-                            class: "social",
-                            contents: [
-                                {
-                                    tag: "strong",
-                                    contents: `<blue>${t("Sur les réseaux")} : </blue>`,
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://www.linkedin.com/company/kuadrado-software",
-                                    target: "_blank",
-                                    contents: "in",
-                                    title: "Linkedin",
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://mastodon.gamedev.place/@kuadrado_software",
-                                    target: "_blank",
-                                    contents: "m",
-                                    title: "Mastodon",
-                                }
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: `Copyleft 🄯 ${new Date()
-                                .getFullYear()} Kuadrado Software | 
-                                ${t("kuadrado-footer-copyleft")}`,
-                        },
-                        {
-                            tag: "div", contents: [
-                                { tag: "span", contents: t("Ce site web est") + " " },
-                                {
-                                    tag: "a", target: "_blank",
-                                    style_rules: { fontWeight: "bold" },
-                                    href: "https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",
-                                    contents: "OPEN SOURCE"
-                                }
-                            ]
-                        }
-                    ],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = Template;
-
-},{"../../config":2,"../../constants":3,"./components/navbar":46,"ks-cheap-translator":4}]},{},[44]);
+!function s(i,o,a){function r(t,e){if(!o[t]){if(!i[t]){var n="function"==typeof require&&require;if(!e&&n)return n(t,!0);if(c)return c(t,!0);throw(n=new Error("Cannot find module '"+t+"'")).code="MODULE_NOT_FOUND",n}n=o[t]={exports:{}},i[t][0].call(n.exports,function(e){return r(i[t][1][e]||e)},n,n.exports,s,i,o,a)}return o[t].exports}for(var c="function"==typeof require&&require,e=0;e<a.length;e++)r(a[e]);return r}({1:[function(e,t,n){t.exports={images_url:"/assets/images"}},{}],2:[function(e,t,n){t.exports={build:{protected_dirs:["assets","style","views","standard"],default_meta_keys:["title","description","image","open_graph","json_ld"]}}},{}],3:[function(e,t,n){t.exports={images_url:"/assets/images",data_url:"/assets/data",translations_url:"/assets/translations"}},{}],4:[function(e,t,n){"use strict";t.exports={locale:"en",supported_languages:["en"],translations:{},translations_url:"",use_url_locale_fragment:!0,local_storage_key:"translator-prefered-language",init(e){return Object.entries(e).forEach(e=>{var[t,e]=e;["supported_languages","use_url_locale_fragment","local_storage_key","translations_url"].includes(t)&&(this[t]=e)}),this.translations_url=this.format_translations_url(this.translations_url),this.supported_languages=this.format_supported_languages(this.supported_languages),new Promise((t,n)=>{const s=(()=>{if(this.use_url_locale_fragment){var e=window.location.pathname.substring(1).split("/")[0];return this.supported_languages.includes(e)?e:""}return""})()||localStorage.getItem(this.local_storage_key)||(e=navigator.language.split("-")[0].toLocaleLowerCase(),this.supported_languages.includes(e)?e:this.supported_languages[0]);var e;fetch(`${this.translations_url}${s}.json`).then(e=>e.json()).then(e=>{this.locale=s,this.translations=e,t()}).catch(e=>{this.locale="en",n(e)})})},format_locale(e){return e.split("-")[0].toLowerCase()},format_translations_url(e){return"/"!==e.charAt(e.length-1)&&(e+="/"),e},format_supported_languages(e){return e.map(e=>this.format_locale(e))},update_translations(s){return s=this.format_locale(s),new Promise((n,t)=>{fetch(`${this.translations_url}${s}.json`).then(e=>e.json()).then(e=>{this.translations=e,this.locale=s,localStorage.setItem(this.local_storage_key,s);const t=window.location.pathname.substring(1).split("/");e=t[0];this.supported_languages.includes(e)&&(t.splice(0,1,s),e=t.join("/"),window.history.replaceState(null,"","/"+e)),n()}).catch(e=>{t(e)})})},trad:function(t,n={}){return t=this.translations[t]||t,Object.keys(n).forEach(e=>{t=t.replace(`{%${e}%}`,n[e])}),t}}},{}],5:[function(e,t,n){"use strict";var s=e("./model/animation"),i=e("./mentalo-engine"),o=e("./model/scene"),a=e("./model/game"),r=e("./model/sound-track"),c=e("./model/scene-types"),l=e("./model/choice"),_=e("./model/game-object"),h=e("./lib/frame-rate-controller"),d=e("./lib/font-tools"),m=e("./lib/color-tools"),e=e("./lib/shape-tools");t.exports={MentaloEngine:i,MtlAnimation:s,MtlScene:o,MtlGame:a,MtlSoundTrack:r,SCENE_TYPES:c,MtlChoice:l,MtlGameObject:_,FrameRateController:h,font_tools:d,color_tools:m,shape_tools:e}},{"./lib/color-tools":6,"./lib/font-tools":7,"./lib/frame-rate-controller":8,"./lib/shape-tools":9,"./mentalo-engine":11,"./model/animation":12,"./model/choice":13,"./model/game":15,"./model/game-object":14,"./model/scene":19,"./model/scene-types":18,"./model/sound-track":20}],6:[function(e,t,n){"use strict";function s(e){return(e=e.toLowerCase(0)).includes("rgb")?e.replaceAll(/[a-z\(\)w]/g,"").split(",").slice(0,3).map(e=>parseInt(e)):e.replace("#","").match(/.{0,2}/g).slice(0,3).map(e=>parseInt(e,16))}function i(e){return s(e).reduce((e,t)=>e+t/3,0)}function o(e){return`#${e.slice(0,3).map(e=>{e=e.toString(16);return e.length<2?"0"+e:e}).join("")}`}t.exports={get_average_rgb_color_tone:i,get_optimal_visible_foreground_color:function(e){const t=document.createElement("canvas");t.width=1,t.height=1;const n=t.getContext("2d");return n.fillStyle=e,n.fillRect(0,0,1,1),e=127<i(e)?"#0000003f":"#ffffff3f",n.fillStyle=e,n.fillRect(0,0,1,1),o(Array.from(n.getImageData(0,0,1,1).data))},color_str_to_rgb_array:s,rgb_array_to_hex:o,rgba_array_to_hex:function(e){return`#${(e=4===e.length?e:e.concat([255])).slice(0,4).map(e=>{e=e.toString(16);return e.length<2?"0"+e:e}).join("")}`},same_rgba:function(e,t){return e[0]===t[0]&&e[1]===t[1]&&e[2]===t[2]&&e[3]===t[3]}}},{}],7:[function(e,t,n){"use strict";const s=[{category:"Sans serif",values:[{value:"sans-serif",text:"sans-serif"},{text:"Arial",value:"Arial, sans-serif"},{text:"Helvetica",value:"Helvetica, sans-serif"},{text:"Verdana",value:"Verdana, sans-serif"},{text:"Trebuchet MS",value:"Trebuchet MS, sans-serif"},{text:"Noto Sans",value:"Noto Sans, sans-serif"},{text:"Gill Sans",value:"Gill Sans, sans-serif"},{text:"Avantgarde",value:"Avantgarde, TeX Gyre Adventor, URW Gothic L, sans-serif"},{text:"Optima",value:"Optima, sans-serif"},{text:"Arial Narrow",value:"Arial Narrow, sans-serif"}]},{category:"Serif",values:[{text:"serif",value:"serif"},{text:"Times",value:"Times, Times New Roman, serif"},{text:"Didot",value:"Didot, serif"},{text:"Georgia",value:"Georgia, serif"},{text:"Palatino",value:"Palatino, URW Palladio L, serif"},{text:"Bookman",value:"Bookman, URW Bookman L, serif"},{text:"New Century Schoolbook",value:"New Century Schoolbook, TeX Gyre Schola, serif"},{text:"American Typewriter",value:"American Typewriter, serif"}]},{category:"Monospace",values:[{text:"monospace",value:"monospace"},{text:"Andale Mono",value:"Andale Mono, monospace"},{text:"Courrier New",value:"Courier New, monospace"},{text:"Courrier",value:"Courier, monospace"},{text:"FreeMono",value:"FreeMono, monospace"},{text:"OCR A Std",value:"OCR A Std, monospace"},{text:"DejaVu Sans Mono",value:"DejaVu Sans Mono, monospace"}]},{category:"Cursive",values:[{text:"cursive",value:"cursive"},{text:"Comic Sans MS",value:"Comic Sans MS, Comic Sans, cursive"},{text:"Apple Chancery",value:"Apple Chancery, cursive"},{text:"Bradley Hand",value:"Bradley Hand, cursive"},{text:"Brush Script MT",value:"Brush Script MT, Brush Script Std, cursive"},{text:"Snell Roundhand",value:"Snell Roundhand, cursive"},{text:"URW Chancery L",value:"URW Chancery L, cursive"}]}],i=[{value:"left",text:"Left"},{value:"right",text:"Right"},{value:"center",text:"Center"}],o=[{value:"normal",text:"Normal"},{value:"italic",text:"Italic"}],a=[{value:"1",text:"Thin"},{value:"normal",text:"Normal"},{value:"900",text:"Bold"}];function r(e={}){const{font_size:t=20,font_family:n="sans-serif",font_style:s="normal",font_weight:i="normal"}=e;return`${s} normal ${i} ${t.toFixed()}px ${n}`}t.exports={get_canvas_font:r,get_font_options:function(){return{font_families:s,text_align_options:i,font_style_options:o,font_weight_options:a}},get_canvas_char_size:function(e,t){e.save(),e.font=r(t);var n="Lorem ipsum dolor Sit amet, Consectetur adipiscing elit",s=e.measureText(n).width/n.length,n=(t=1.1*e.measureText("M").width)+t/4;return e.restore(),{width:s,height:t,text_line_height:n,interline_height:t/4}},FONT_FAMILIES:s,TEXT_ALIGN_OPTIONS:i,FONT_STYLE_OPTIONS:o,FONT_WEIGHT_OPTIONS:a}},{}],8:[function(e,t,n){"use strict";t.exports=class{constructor(e){this.tframe=performance.now(),this.interval=1e3/e,this.initial=!0}nextFrameReady(){if(this.initial)return!(this.initial=!1);var e=performance.now(),t=e-this.tframe,n=t>this.interval;return n&&(this.tframe=e-t%this.interval),n}}},{}],9:[function(e,t,n){"use strict";t.exports={draw_rect:function(e,t,n,s,i,o={rounded_corners_radius:0,border:{width:0,color:"rgba(0,0,0,0)"},fill_color:"black"}){var{rounded_corners_radius:a=0,border:r={width:0,color:"rgba(0,0,0,0)"},fill_color:c="black",fill_image:l}=o,a=(o=Math.min(s,i))/2<a?o/2:a;e.save(),e.beginPath(),e.arc(t+a,n+a,a,Math.PI,3*Math.PI/2),e.lineTo(t+s-a,n),e.arc(t+s-a,n+a,a,3*Math.PI/2,0),e.lineTo(t+s,n+i-a),e.arc(t+s-a,n+i-a,a,0,Math.PI/2),e.lineTo(t+a,n+i),e.arc(t+a,n+i-a,a,Math.PI/2,Math.PI),e.closePath(),e.clip(),l?e.drawImage(l.src,0,0,l.src.width,l.src.height,l.dx,l.dy,l.dw,l.dh):(e.fillStyle=c,e.fillRect(t,n,s,i)),0<r.width&&(e.strokeStyle=r.color,e.lineWidth=r.width,e.stroke()),e.restore()}}},{}],10:[function(e,t,n){"use strict";const{get_canvas_font:l,get_canvas_char_size:_}=e("./font-tools");t.exports={draw_text_in_bounds:function(i,s,o,a,r,e){i.save();const t=_(i,a).text_line_height;i.font=l(a),i.fillStyle=a.font_color,i.textAlign=a.text_align,i.textBaseline="top";const n=(()=>{var{padding:e=0}=a,{left:t,top:n,width:s}=o;switch(i.textAlign){case"left":return{x:t+e,y:n+e};case"right":return{x:t+s-e,y:n+e};case"center":return{x:t+s/2,y:n+e};default:return{x:t+e,y:n+e}}})();r.stream=r.stream||{line_chars:0,line_index:0,complete:!1};const c=e?s.map((e,t)=>{const n=r.stream;if(t<n.line_index||n.complete)return e;if(t>n.line_index)return"";t=e.slice(0,++n.line_chars);return t.length===e.length&&(n.complete=s.length-1===n.line_index,n.line_index=n.complete?n.line_index:n.line_index+1,n.line_chars=0),t}):(r.stream.complete=!0,s);return c.forEach(e=>{i.fillText(e,n.x,n.y),n.y+=t}),i.restore(),r.stream.complete}}},{"./font-tools":7}],11:[function(e,t,n){"use strict";const s=e("./model/game"),i=e("./model/scene-types"),o=e("./render/render"),{supported_locales:a,get_translated:r}=e("./translation");t.exports=class{constructor(e){this.game=new s,this.game.load_data(e.game_data),this.game.on_load_resources(()=>this.loading=!1),this.use_locale=e.use_locale&&a.includes(e.use_locale)?e.use_locale:"en",this.escape_listener=e=>{e=e.key.toLowerCase();"escape"!==e&&"q"!==e||this.quit()},window.addEventListener("keydown",this.escape_listener),this.on_quit_game=function(){this.used_default_container&&this.container.remove(),window.removeEventListener("keydown",this.escape_listener),e.on_quit_game&&e.on_quit_game()},this.used_default_container=!1,this.container=e.container||(()=>{const e=document.createElement("div");return e.style.display="flex",e.style.justifyContent="center",e.style.alignItems="center",e.style.position="absolute",e.style.top=0,e.style.bottom=0,e.style.left=0,e.style.right=0,e.style.zIndex=1e3,e.style.overflow="hidden",document.body.appendChild(e),this.used_default_container=!0,e})(),this.render=new o({container:this.container,fullscreen:e.fullscreen,frame_rate:e.frame_rate,get_game_settings:this.get_game_settings.bind(this),get_game_scenes:this.get_game_scenes.bind(this),get_scene:this.get_scene.bind(this),get_scene_index:this.get_scene_index.bind(this),get_inventory:this.get_inventory.bind(this),on_game_object_click:this.on_game_object_click.bind(this),on_drop_inventory_object:this.on_drop_inventory_object.bind(this),on_choice_click:this.on_choice_click.bind(this)}),this.loading=!0,this.will_quit=!1}get_game_settings(e){return this.game.game_ui_options[e]}get_game_scenes(){return this.game.scenes}get_scene(){return this.game.get_scene()}get_scene_index(){return this.game.scenes.indexOf(this.get_scene())}get_inventory(){return this.game.state.inventory}quit(){this.will_quit=!0}__quit(){window.cancelAnimationFrame(window.mentalo_engine_animation_id),this.render.exit_fullscreen(),this.render.clear_event_listeners(),this.game.get_soundtrack().stop(),this.on_quit_game()}init(){window.requestAnimationFrame=window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame,window.cancelAnimationFrame=window.cancelAnimationFrame||window.mozCancelAnimationFrame,window.mentalo_engine_animation_id&&window.cancelAnimationFrame(window.mentalo_engine_animation_id),this.render.init(),this.game.scenes.find(e=>e.animation.empty)&&(alert(r("Some scenes have an empty image, game cannot be loaded",this.use_locale)),this.quit())}go_to_scene(e){this.render.reset_user_error_popup(),this.render.clear_event_listeners(),this.game.get_soundtrack().stop(),this.game.go_to_scene(e),this.render.clear_children(),this.render.reset_text_box_visibility()}on_game_object_click(e){this.game.inventory_has_empty_slot()&&this.game.add_object_to_inventory(e)}on_drop_inventory_object(e){this.game.remove_object_from_inventory(e)}on_choice_click(e){const{destination_scene_index:t,use_objects:n}=e;if(-1!==t)if(-2!==t){if(n.value){var s=this.get_inventory();const o=[];for(const a of n.items){var i=Array.from(s).find(e=>e.name===a.name);if(!i)return void this.render.set_user_error_popup({text:n.missing_object_message,stream_text:!0});o.push(i)}o.forEach(t=>n.items.find(e=>t.name===e.name).consume_object&&this.game.consume_game_object(t))}this.go_to_scene(t)}else this.quit();else this.render.set_user_error_popup({text:r("Destination scene has not been set.",this.use_locale)})}on_cinematic_end(){var e=this.game.get_scene();e.end_cinematic_options.quit?this.quit():-1===e.end_cinematic_options.destination_scene_index?this.render.set_user_error_popup({text:r("Next scene has not been set.",this.use_locale),on_close:this.quit.bind(this)}):this.go_to_scene(e.end_cinematic_options.destination_scene_index)}run_game(){if(this.will_quit)this.__quit();else{if(this.loading)this.render.draw_loading();else{this.get_scene()._type===i.CINEMATIC&&(this.game.update_cinematic_timeout(),this.game.is_cinematic_ended()&&this.on_cinematic_end());const e=this.game.get_soundtrack();e.loaded&&!e.is_playing()&&e.play(),this.render.draw_game()}window.mentalo_engine_animation_id=requestAnimationFrame(this.run_game.bind(this))}}}},{"./model/game":15,"./model/scene-types":18,"./render/render":23,"./translation":35}],12:[function(e,t,n){"use strict";class s extends e("./loadable"){constructor(){super(new Image,"image","load"),this.image.onload=()=>this.update_dimensions(),this.name="",this.dimensions={width:0,height:0},this.frame_nb=1,this.frame=0,this.speed=1,this.play_once=!1,this.initialized=!1,this.finished=!1}update_dimensions(){this.dimensions={width:this.image.width/this.frame_nb,height:this.image.height},this.canvas_precalc&&delete this.canvas_precalc}init(e){this.empty=""===e.src,this.name=e.name,this.image.src=e.src,this.frame_nb=e.frame_nb,this.frame=0,this.speed=e.speed,this.play_once=e.play_once,this.initialized=!0}update_frame(e){this.finished=this.play_once&&this.frame===this.frame_nb-1,1<this.frame_nb&&e%this.speed==0&&!this.finished&&(this.frame=this.frame+1<=this.frame_nb-1?this.frame+1:0)}reset_frame(){this.finished=!1,this.frame=0}}t.exports=s},{"./loadable":17}],13:[function(e,t,n){"use strict";t.exports=class{constructor(e={text:"",destination_scene_index:-1,use_objects:{value:!1,items:[],missing_object_message:""}}){this.load_data(e)}load_data(e){this.text=e.text,this.destination_scene_index=e.destination_scene_index,this.use_objects=e.use_objects}}},{}],14:[function(e,t,n){"use strict";class s extends e("./loadable"){constructor(){super(new Image,"image","load"),this.name="",this.position={x:0,y:0},this.state={}}load_data(e){this.image.src=e.image,this.name=e.name,this.position=e.position}get_dimensions(){return{width:this.image.width,height:this.image.height}}get_bounds(){var e=this.get_dimensions();return{top:this.position.y,right:this.position.x+e.width,bottom:this.position.y+e.height,left:this.position.x}}}t.exports=s},{"./loadable":17}],15:[function(e,t,n){"use strict";const s=e("./scene"),i=e("./scene-types");t.exports=class{constructor(){this.name="",this.scenes=[new s],this.game_ui_options={general:{background_color:"#000000",animation_canvas_dimensions:{width:600,ratio:"4:3",height:function(){var e=this.ratio.split(":").map(e=>parseInt(e)),e=e[1]/e[0];return Number((this.width*e).toFixed())}}},text_boxes:{background_color:"#222222",font_size:20,font_style:"normal",font_weight:"normal",font_family:"Arial, sans-serif",font_color:"#ffffff",text_align:"left",padding:20,margin:20,border_width:0,rounded_corners_radius:0},choices_panel:{background_color:"#222222",font_size:20,font_family:"sans",font_color:"#ffffff",font_style:"normal",text_align:"left",font_weight:"normal",container_padding:20,choice_padding:10,active_choice_background_color:"rgba(255,255,255,.4)",active_choice_border_width:0,active_choice_rounded_corners_radius:0},inventory:{background_color:"#222222",columns:1,rows:4,gap:10,padding:20,slot_rounded_corner_radius:0,slot_border_width:2}},this.starting_scene_index=0,this.state={scene:this.starting_scene_index,inventory:new Set,cinematic_timeout:{inc:0,last_update_time:-1,timeout:!1}},this.load_state={loaded_scenes:0}}get_scene(){return this.scenes[this.state.scene]}get_soundtrack(){return this.get_scene().sound_track}go_to_scene(e){const t=this.get_scene();t.animation.reset_frame(),t._type==i.CINEMATIC&&(this.state.cinematic_timeout={inc:0,last_update_time:-1,timeout:!1}),this.state.scene=e}update_cinematic_timeout(){var e;this.state.cinematic_timeout.timeout||(e=(new Date).getTime(),-1===this.state.cinematic_timeout.last_update_time&&(this.state.cinematic_timeout.last_update_time=e),this.state.cinematic_timeout.inc=e-this.state.cinematic_timeout.last_update_time,this.state.cinematic_timeout.timeout=1e3*this.get_scene().cinematic_duration<=this.state.cinematic_timeout.inc)}is_cinematic_ended(){var e=this.get_scene();return 0===e.cinematic_duration?e.animation.finished:this.state.cinematic_timeout.timeout}add_object_to_inventory(e){this.state.inventory.add(e)}inventory_has_empty_slot(){var{columns:e,rows:t}=this.game_ui_options.inventory;return this.state.inventory.size<e*t}remove_object_from_inventory(e){this.state.inventory.delete(e)}consume_game_object(t){this.remove_object_from_inventory(t);for(const n of this.scenes){var e=n.game_objects.find(e=>e===t);if(e){n.game_objects.splice(n.game_objects.indexOf(e),1);break}}}all_resources_loaded(){return this.load_state.loadable_elements===this.load_state.loaded_elements}get_game_objects(){return this.scenes.reduce((e,t)=>e.concat(t.game_objects),[])}load_data(e){this.name=e.name,this.starting_scene_index=e.starting_scene_index||0,this.state.scene=this.starting_scene_index,this.scenes=e.scenes.map(e=>{const t=new s;return t.load_data(e),t.on_load_group_complete(()=>{this.load_state.loaded_scenes++,this.load_state.loaded_scenes===this.scenes.length&&this.on_load_resources_callback&&this.on_load_resources_callback()}),t}),this.game_ui_options=Object.assign(e.game_ui_options,{general:{background_color:e.game_ui_options.general.background_color,animation_canvas_dimensions:Object.assign(e.game_ui_options.general.animation_canvas_dimensions,{height:function(){var e=this.ratio.split(":").map(e=>parseInt(e)),e=e[1]/e[0];return Number((this.width*e).toFixed())}})}})}on_load_resources(e){this.on_load_resources_callback=e}}},{"./scene":19,"./scene-types":18}],16:[function(e,t,n){"use strict";t.exports=class{constructor(){this.loadable_elements=0,this.loaded_elements=0}add_loadable(e){this.loadable_elements++,e.on_load(()=>{this.loaded_elements++,this.loadable_elements===this.loaded_elements&&this.on_load_group_complete_custom_callback&&this.on_load_group_complete_custom_callback()})}on_load_group_complete(e){this.on_load_group_complete_custom_callback=e}}},{}],17:[function(e,t,n){"use strict";t.exports=class{constructor(e,t,n){this.loaded=!1,this.init_loadable_element(e,t,n)}init_loadable_element(e,t,n){this[t]=e,this.load_listener=()=>{this.loaded=!0,this.on_load_callback(),this.clear()},this[t].addEventListener(n,this.load_listener),this.clear=()=>{this[t].removeEventListener(n,this.load_listener)}}on_load(e){this.on_load_custom_callback=e}on_load_callback(){this.loaded=!0,this.on_load_custom_callback&&this.on_load_custom_callback()}}},{}],18:[function(e,t,n){t.exports={PLAYABLE:"Playable",CINEMATIC:"Cinematic"}},{}],19:[function(e,t,n){"use strict";const s=e("./scene-types"),i=e("./animation"),o=e("./sound-track"),a=e("./choice"),r=e("./game-object");class c extends e("./loadable-group"){constructor(){super();var e={name:"",_type:s.PLAYABLE,animation:new i,sound_track:new o,choices:[],text_box:"",game_objects:[],cinematic_duration:0,end_cinematic_options:{destination_scene_index:-1,quit:!1}};this.name=e.name,this._type=e._type,this.animation=e.animation,this.sound_track=e.sound_track,this.choices=e.choices,this.text_box=e.text_box,this.game_objects=e.game_objects,this.cinematic_duration=e.cinematic_duration||0,this.end_cinematic_options=e.end_cinematic_options}load_data(e){this.name=e.name,this._type=e._type;const t=new i;t.init(e.animation),this.animation.clear(),this.animation=t,this.animation.image.src&&this.add_loadable(this.animation);const n=new o;n.init(e.sound_track),this.sound_track.clear(),this.sound_track=n,this.sound_track.src&&this.add_loadable(this.sound_track),this.choices=e.choices.map(e=>new a(e)),this.text_box=e.text_box,this.cinematic_duration=e.cinematic_duration||0,this.game_objects=e.game_objects.map(e=>{const t=new r;return t.load_data(e),this.add_loadable(t),t}),this.end_cinematic_options=e.end_cinematic_options}}t.exports=c},{"./animation":12,"./choice":13,"./game-object":14,"./loadable-group":16,"./scene-types":18,"./sound-track":20}],20:[function(e,t,n){"use strict";class s extends e("./loadable"){constructor(){super(new Audio,"audio","loadeddata"),this.name="",this.initialized=!1}reset(){this.init_loadable_element(new Audio,"audio","loadeddata"),this.name="",this.initialized=!1}init(e){this.empty=""===e.src,this.audio.src=e.src,this.name=e.name,this.audio.loop=e._loop,this.initialized=!0}play(){this.audio.play()}stop(){this.audio.pause(),this.audio.currentTime=0}is_playing(){return!this.audio.paused}}t.exports=s},{"./loadable":17}],21:[function(e,t,n){"use strict";const o=e("./choices-panel-metrics");t.exports=class{constructor(e){this.params=e,this.image={width:0,height:0},this.width=0,this.height=0,this.inventory={width:0,height:0},this.choices_panel={width:0,height:0},this.scale_factor=1,this.init_dimensions()}init_dimensions(){const{get_game_settings:e}=this.params,t=e("general");var n=t.animation_canvas_dimensions.height(),s=t.animation_canvas_dimensions.width,i=this.get_inventory_base_width(),o=s+i;this.choices_panel_metrics=this.get_choices_panel_metrics();var a,r=window.screen.width-200,c=window.screen.height-200,l=n+this.choices_panel_metrics.get_total_height(),_=o/l;_<window.screen.width/window.screen.height?this.width=(a=c*_)<=r?a:r:(this.height=(r=l/o*r)<=c?r:c,this.width=this.height*_),this.scale_factor=this.width/o,this.choices_panel_metrics=this.choices_panel_metrics.to_scaled(this.scale_factor),this.image={width:s*this.scale_factor,height:n*this.scale_factor},this.inventory={width:i*this.scale_factor,height:this.image.height},this.choices_panel={width:this.width,height:this.choices_panel_metrics.get_total_height()},this.height=this.image.height+this.choices_panel.height}get_inventory_base_width(){const{get_game_settings:e}=this.params;var t=e("general").animation_canvas_dimensions.height(),n=e("inventory"),s=n.gap,i=t-2*n.padding,t=(n.rows-1)*s,s=(n.columns-1)*s,t=(i-t)/n.rows;return n.columns*t+2*n.padding+s}get_choices_panel_metrics(){const{get_game_settings:e,get_game_scenes:t}=this.params;var n=e("choices_panel"),s=e("general"),i=n.container_padding,s=s.animation_canvas_dimensions.width+this.get_inventory_base_width()-2*i,i=t().map(e=>({choices:e.choices}));return new o({settings:n,container_width:s,scenes_choices:i})}}},{"./choices-panel-metrics":22}],22:[function(e,t,n){"use strict";const{get_canvas_char_size:o}=e("../lib/font-tools");t.exports=class t{constructor(e){this.params=e;var{scale_factor:e=1}=this.params;this.container_width=this.params.container_width*e,this.settings=this.get_scaled_settings(e),this.scenes_formatted_choices=this.get_scenes_formatted_choices(),this.container_padding_height=2*this.settings.container_padding,this.rows=this.get_rows_metrics()}get_scaled_settings(e){var{settings:t}=this.params;return Object.assign({...t},{font_size:t.font_size*e,active_choice_border_width:t.active_choice_border_width*e,active_choice_rounded_corners_radius:t.active_choice_rounded_corners_radius*e,choice_padding:t.choice_padding*e,container_padding:t.container_padding*e})}get_rows_metrics(e,t){var{settings:n}=this,s=this.get_choices_max_lines_per_row(e,t),e=(i=o(window.mentalo_drawing_context,n)).text_line_height,t=i.interline_height,i=2*n.choice_padding,n=s[0]*e-t,t=0<s[1]?s[1]*e-t:0;return[{text_height:n,padding_height:i},{text_height:t,padding_height:0<t?i:0}]}get_choices_max_lines_per_row(e,t){e=e||this.get_max_choices_row_per_scene();const i=t||this.scenes_formatted_choices,o=[0,0];return Array.from({length:e}).forEach((e,t)=>{const n=0===t?[0,2]:[2,4];var s=i.map(e=>Math.max(...e.slice(...n).map(e=>e.text_lines.length))).reduce((e,t)=>Math.max(e,t),0);o[t]=s}),o}get_max_choices_row_per_scene(e){const t=e?this.params.scenes_choices.slice(e,e+1):this.params.scenes_choices;let n=1;return 2<Math.max(...t.map(e=>e.choices.length))&&(n=2),n}get_scenes_formatted_choices(){const{scenes_choices:e}=this.params;var{settings:t,container_width:n}=this,{choice_padding:s}=t;const i=.9*(n/2-2*s)/o(window.mentalo_drawing_context,t).width;return e.map(e=>e.choices.map(e=>{const t=e.text.split(" "),n=[""];let s=0;return t.forEach(e=>{(n[s]+e).length>=i&&(s++,n.push("")),n[s]=`${n[s]}${""===n[s]?"":" "}${e}`}),Object.assign({...e},{text_lines:n})}))}get_total_height(e){return(e=e||this.rows).reduce((e,t)=>e+t.text_height+t.padding_height,0)+this.container_padding_height}to_scaled(e){return new t({...this.params,scale_factor:e})}get_minimum_panel_height_for_scene(e){var e=2<(t=this.scenes_formatted_choices.slice(e,e+1))[0].length?2:1,t=this.get_rows_metrics(e,t);return this.get_total_height(t)}get_one_choices_row_metrics(e,t){return e=this.scenes_formatted_choices.slice(e,e+1),t=this.get_rows_metrics(t+1,e)[t],Object.assign(t,{text_height:t.text_height,padding_height:t.padding_height})}}},{"../lib/font-tools":7}],23:[function(e,t,n){"use strict";const s=e("../lib/frame-rate-controller"),{get_optimal_visible_foreground_color:u}=e("../lib/color-tools"),{get_canvas_char_size:p}=e("../lib/font-tools"),i=e("../model/scene-types"),f=e("./ui-components/choice-cpt"),o=e("./ui-components/choices-panel-cpt"),b=e("./ui-components/closing-icon-cpt"),a=e("./ui-components/game-object-cpt"),r=e("./ui-components/inventory-cpt"),v=e("./ui-components/inventory-object-cpt"),w=e("./ui-components/inventory-slot-cpt"),c=e("./ui-components/scene-animation-cpt"),x=e("./ui-components/text-box-cpt"),y=e("./ui-components/user-error-popup"),l=e("./canvas-dimensions");t.exports=class{constructor(e){this.params=e;e=this.params.frame_rate||30;this.fps_controller=new s(e),this.params.container.style.backgroundColor=this.params.get_game_settings("general").background_color,this.canvas=document.createElement("canvas"),this.canvas.style.backgroundColor="black",window.mentalo_drawing_context=this.canvas.getContext("2d"),this.canvas_dimensions={},this.canvas_zones={},this.event_listeners={game_objects:[],inventory:[],text_box:[]},this.state={user_error_popup:{is_set:!1,text:"",on_close:function(){}}},this.loading_frame=0}init(){this.params.fullscreen&&this.set_full_screen(),this.set_canvas_dimensions(),this.create_canvas_zones(),this.params.container.appendChild(this.canvas);const e=window.mentalo_drawing_context;e.mozImageSmoothingEnabled=!1,e.webkitImageSmoothingEnabled=!1,e.msImageSmoothingEnabled=!1,e.imageSmoothingEnabled=!1,this.create_components()}clear_event_listeners(){Object.values(this.components).forEach(e=>{e.clear_event_listeners()})}clear_children(t={exclude:[]}){Object.values(this.components).forEach(e=>{e.clear_children(t)})}reset_text_box_visibility(){this.components.scene_animation.set_text_box_visibility(!0)}set_full_screen(){const e=document.body;e.requestFullScreen=e.requestFullScreen||e.webkitRequestFullScreen||e.msRequestFullscreen||e.mozRequestFullScreen;try{e.requestFullScreen()}catch(e){console.error(e.message)}}exit_fullscreen(){document.fullscreenElement&&document.exitFullscreen()}set_user_error_popup(e){this.state.user_error_popup={is_set:!0,text:e.text,stream_text:!!e.stream_text,on_close:function(){e.on_close&&e.on_close()}},this.clear_event_listeners(),this.clear_children({exclude:["TextBoxCpt","InventoryCpt","ChoicesPanelCpt","GameObjectCpt"]})}clear_user_error_popup(){this.reset_user_error_popup(),this.clear_event_listeners(),this.clear_children({exclude:["TextBoxCpt","InventoryCpt","ChoicesPanelCpt","GameObjectCpt"]})}reset_user_error_popup(){this.state.user_error_popup={is_set:!1,text:"",on_close:function(){}}}on_close_user_error_popup(){this.state.user_error_popup.on_close(),this.clear_user_error_popup()}set_canvas_dimensions(){var{get_game_settings:e,get_game_scenes:t}=this.params;this.canvas_dimensions=new l({get_game_settings:e,get_game_scenes:t}),this.canvas.width=this.canvas_dimensions.width,this.canvas.height=this.canvas_dimensions.height}get_scaled_choices_panel_settings(){return this.canvas_dimensions.choices_panel_metrics.settings}get_scaled_inventory_settings(){var{scale_factor:e}=this.canvas_dimensions;const{get_game_settings:t}=this.params;var n=t("inventory");return Object.assign({...n},{slot_rounded_corner_radius:n.slot_rounded_corner_radius*e,slot_border_width:n.slot_border_width*e,gap:n.gap*e,padding:n.padding*e})}get_scaled_text_boxes_settings(){var{scale_factor:e}=this.canvas_dimensions;const{get_game_settings:t}=this.params;var n=t("text_boxes");return Object.assign({...n},{font_size:n.font_size*e,padding:n.padding*e,margin:n.margin*e,rounded_corners_radius:n.rounded_corners_radius*e,border_width:n.border_width*e})}create_canvas_zones(){var e=this.canvas_dimensions,t=this.get_scaled_inventory_settings(),n=this.get_scaled_choices_panel_settings();this.canvas_zones={root:{left:0,top:0,right:e.width,bottom:e.height,width:e.width,height:e.height,padding:0},scene_animation:{left:0,top:0,right:e.image.width,bottom:e.image.height,width:e.image.width,height:e.image.height,clear_color:"black",padding:0},inventory:{left:e.image.width,top:0,right:e.width,bottom:e.inventory.height,width:e.width-e.image.width,height:e.inventory.height,clear_color:t.background_color,padding:t.padding},choices_panel:{left:0,top:e.image.height,right:e.width,bottom:e.height,width:e.width,height:e.height-e.image.height,clear_color:n.background_color,padding:n.container_padding}}}scene_is_not_cinematic(){return this.params.get_scene()._type===i.PLAYABLE}create_components(){const{get_game_settings:e,get_inventory:d,get_scene:m,get_game_scenes:l,get_scene_index:_}=this.params,{scale_factor:h}=this.canvas_dimensions,g=this.scene_is_not_cinematic.bind(this);this.components={scene_animation:new c({bounding_zone:this.canvas_zones.scene_animation,get_animation:()=>m().animation,next_frame_ready:()=>this.fps_controller.nextFrameReady(),get_children:()=>{const e=m();return e.game_objects.map(t=>{var e=this.canvas_zones.scene_animation,n={x:t.position.x*h+e.left,y:t.position.y*h+e.top},e={w:t.image.width*h,h:t.image.height*h};const s={left:n.x,right:n.x+e.w,top:n.y,bottom:n.y+e.h,width:e.w,height:e.h};e={bounding_zone:s,position:n,dimensions:e,image:t.image,is_in_inventory:()=>d().has(t)};const i=new a(e);return i.add_event_listener({event_type:"mousemove",listener:e=>{d().has(t)||(e=e.offsetX>=s.left&&e.offsetX<=s.right&&e.offsetY>=s.top&&e.offsetY<=s.bottom,i.state.draw_border=e)}}),i.add_event_listener({event_type:"click",listener:e=>{!d().has(t)&&e.offsetX>=s.left&&e.offsetX<=s.right&&e.offsetY>=s.top&&e.offsetY<=s.bottom&&(this.params.on_game_object_click(t),this.clear_event_listeners(),this.clear_children({exclude:"TextBoxCpt"}))}}),i}).concat(e.text_box?[(()=>{const e=this.get_scaled_text_boxes_settings();var t,n,s,n=(t=this.canvas_zones.scene_animation,o=e.padding,i=e.margin,n=t.left+i,s=t.right-i,i=t.bottom-i,{left:n,right:s,top:o=i-2*o,bottom:i,width:s-n,height:i-o}),i=10*h,o={x:n.right-i/2,y:n.top+i/2};const a={color:e.font_color,radius:i,center:o,background_color:e.background_color,line_width:Math.floor(2*h),bounding_zone:{left:o.x-i,right:o.x+i,top:o.y-i,bottom:o.y+i,width:2*i,height:2*i}},r=new x({text:m().text_box,settings:e,bounding_zone:n,get_visibility_state:()=>this.components.scene_animation.state.text_box_visible,get_children:()=>[new b(a)]}),c=r.children[0];return r.children[0].add_event_listener({event_type:"click",listener:e=>{var t=c.params.bounding_zone,t=e.offsetX>=t.left&&e.offsetX<=t.right&&e.offsetY>=t.top&&e.offsetY<=t.bottom;const{scene_animation:n}=this.components;t&&n.state.text_box_visible&&n.set_text_box_visibility(!1)}}),r})()]:[]).concat(this.state.user_error_popup.is_set?[(()=>{const{text:e,stream_text:t}=this.state.user_error_popup;var n=this.get_scaled_text_boxes_settings(),s=this.canvas_zones.scene_animation,i=p(window.mentalo_drawing_context,n),o=s.width/2,a=2*i.width;const r=(o-2*a)/i.width,c=[""];let l=0;e.split(" ").forEach(e=>{`${c[l]}${e} `.length>r&&(l++,c.push("")),c[l]+=`${e} `});var _=i.text_line_height,i=c.length*_+2*a-i.interline_height;const h=n.background_color,d=n.font_color,m={left:s.left+s.width/2-o/2,top:s.top+s.height/2-i/2,right:s.left+s.width/2+o/2,bottom:s.top+s.height/2-i/2+i,width:o,height:i,clear_color:h};a={settings:n,bounding_zone:m,modal_bounds:this.canvas_zones.scene_animation,clear_modal_color:"#0004",text_lines:c,padding:a,stream_text:t,get_children:()=>{var e={x:m.right-5,y:m.top+5};const t={left:e.x-15,right:e.x+15,top:e.y-15,bottom:e.y+15,width:30,height:30};e={color:d,radius:15,center:e,background_color:h,line_width:2,bounding_zone:t};const n=new b(e);return n.add_event_listener({event_type:"click",listener:e=>{e.offsetX>=t.left&&e.offsetX<=t.right&&e.offsetY>=t.top&&e.offsetY<=t.bottom&&this.on_close_user_error_popup()}}),[n]}};return new y(a)})()]:[])}}),inventory:new r({bounding_zone:this.canvas_zones.inventory,get_inventory:d,is_visible:g,invisible_clear_color:e("general").background_color,get_children:()=>{const i=this.get_scaled_inventory_settings(),a={left:this.canvas_zones.inventory.left+i.padding,top:this.canvas_zones.inventory.top+i.padding,right:this.canvas_zones.inventory.right-i.padding,bottom:this.canvas_zones.inventory.bottom-i.padding,width:this.canvas_zones.inventory.width-2*i.padding,height:this.canvas_zones.inventory.height-2*i.padding},r=i.gap;const c=(a.height-(i.rows-1)*r)/i.rows;var e=c*i.columns+r*(i.columns-1);const l=(a.width-e)/2,_=[];let h=0;return Array.from({length:i.rows}).forEach((e,t)=>{const s=a.top+t*(c+r);Array.from({length:i.columns}).forEach((e,t)=>{t=a.left+t*(c+r)+l;const o={left:t,top:s,right:t+c,bottom:s+c,width:c,height:c},n=u(i.background_color);t={bounding_zone:o,stroke_color:n,is_visible:g,settings:i,get_children:()=>{const s=new v({slot_index:h,is_visible:g,settings:i,stroke_color:n,get_game_object:e=>{const t=Array.from(d())[e];if(t){var n=t.get_dimensions(),e=n.width/n.height;const s={width:0,height:0},i={x:0,y:0};return 1<e?(s.width=c,s.height=c*(n.height/n.width),i.x=o.left,i.y=o.top+(c/2-s.height/2)):e<1?(s.height=c,s.width=c*e,i.y=o.top,i.x=o.left+(c/2-s.width/2)):(s.width=c,s.height=c,i.x=o.left,i.y=o.top),{ref:t,position:i,dimensions:s}}},bounding_zone:o,get_children:()=>[new b({is_visible:g,color:"#bf5e43",center:{x:o.left+c/2,y:o.top+c/2},radius:c/4,line_width:2<c/20?c/20:2,bounding_zone:{...o,clear_color:"rgba(0,0,0,0.2)"}})]});return s.add_event_listener({event_type:"mousemove",listener:e=>{var t=s.params.get_game_object(s.params.slot_index),n=s.params.bounding_zone;s.state.draw_inventory_close_icon=s.params.is_visible()&&!!t&&m().game_objects.includes(t.ref)&&e.offsetX>=n.left&&e.offsetX<=n.right&&e.offsetY>=n.top&&e.offsetY<=n.bottom}}),s.children[0].add_event_listener({event_type:"click",listener:e=>{if(!s.params.is_visible())return!1;var t=s.params.get_game_object(s.params.slot_index),n=s.params.bounding_zone,n=e.offsetX>=n.left&&e.offsetX<=n.right&&e.offsetY>=n.top&&e.offsetY<=n.bottom;t&&n&&m().game_objects.includes(t.ref)&&(this.params.on_drop_inventory_object(t.ref),this.clear_event_listeners(),this.clear_children({exclude:"TextBoxCpt"}))}}),[s]}},t=new w(t);_.push(t),h++})}),_}}),choices_panel:new o({bounding_zone:this.canvas_zones.choices_panel,minimum_bounding_zone:()=>{var e=this.canvas_zones.choices_panel,t=this.canvas_dimensions.choices_panel_metrics.get_minimum_panel_height_for_scene(_()),t=this.canvas_dimensions.choices_panel_metrics.get_total_height()-t;return Object.assign({...e},{bottom:e.bottom-t,height:e.height-t})},is_visible:g,invisible_clear_color:e("general").background_color,get_children:()=>Array.from({length:2*this.canvas_dimensions.choices_panel_metrics.get_max_choices_row_per_scene(_())}).map((e,n)=>{var t=this.get_scaled_choices_panel_settings();const s={left:this.canvas_zones.choices_panel.left+t.container_padding,right:this.canvas_zones.choices_panel.right-t.container_padding,top:this.canvas_zones.choices_panel.top+t.container_padding,bottom:this.canvas_zones.choices_panel.bottom-t.container_padding,width:0,height:0};s.width=s.right-s.left,s.height=s.bottom-s.top;var i=s.width/2;const{choices_panel_metrics:o}=this.canvas_dimensions;var a=o.rows.map(e=>e.text_height+e.padding_height);const r={left:s.left+n%2*i,top:s.top+(1<n?1:0)*a[0],right:s.left+n%2*i+i,bottom:s.top+(1<n?1:0)*a[1]+a[1<n?1:0],width:i,height:a[1<n?1:0]},c=new f({is_visible:g,minimum_bounding_zone:()=>{var e,t;return c.minimum_bounding_zone||(e=1<n?1:0,e=(t=this.canvas_dimensions.choices_panel_metrics.get_one_choices_row_metrics(_(),e)).text_height+t.padding_height,t=(()=>{const e=this.components.choices_panel.children;return 2<=e.indexOf(c)?r.top-e[0].params.minimum_bounding_zone().bottom:0})(),c.minimum_bounding_zone=Object.assign({...r},{height:e,top:r.top-t,bottom:r.top-t+e})),c.minimum_bounding_zone},get_formatted_choice:()=>{var e=this.canvas_dimensions.choices_panel_metrics.scenes_formatted_choices[l().indexOf(m())];return e.length-1>=n?e[n]:void 0},settings:t});return c.add_event_listener({event_type:"mousemove",listener:e=>{c.state.active=c.params.is_visible()&&!!c.params.get_formatted_choice()&&c.is_hover(e)}}),c.add_event_listener({event_type:"click",listener:()=>{var e;c.state.active&&(e=c.params.get_formatted_choice(),this.params.on_choice_click(e)&&(this.clear_event_listeners(),this.clear_children()))}}),c})})}}draw_loading(){const e=window.mentalo_drawing_context;e.save(),e.font="25px monospace",e.fillStyle="black",e.fillRect(0,0,window.innerWidth,window.innerHeight),e.fillStyle="white",e.fillText("Loading",50,window.innerHeight/2);var t=Array.from({length:++this.loading_frame}).map(()=>".").join("");e.font="8px monospace",e.fillText(t,50,window.innerHeight/2+20),e.restore()}draw_game(){Object.values(this.components).forEach(e=>e.draw())}}},{"../lib/color-tools":6,"../lib/font-tools":7,"../lib/frame-rate-controller":8,"../model/scene-types":18,"./canvas-dimensions":21,"./ui-components/choice-cpt":24,"./ui-components/choices-panel-cpt":25,"./ui-components/closing-icon-cpt":26,"./ui-components/game-object-cpt":27,"./ui-components/inventory-cpt":28,"./ui-components/inventory-object-cpt":29,"./ui-components/inventory-slot-cpt":30,"./ui-components/scene-animation-cpt":31,"./ui-components/text-box-cpt":32,"./ui-components/user-error-popup":34}],24:[function(e,t,n){"use strict";const{draw_text_in_bounds:l}=e("../../lib/text-tools"),{draw_rect:_}=e("../../lib/shape-tools");class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("ChoiceCpt")}draw(){super.draw();const{get_formatted_choice:e,settings:t}=this.params;var n,s,i,o=this.params.minimum_bounding_zone(),a={left:o.left+t.choice_padding,right:o.right-t.choice_padding,top:o.top+t.choice_padding,bottom:o.bottom-t.choice_padding,width:o.width-2*t.choice_padding,height:o.height-2*t.choice_padding},r=e(),c=window.mentalo_drawing_context;r&&(this.state.active&&({left:n,top:s,width:i,height:o}=o,_(c,n,s,i,o,{fill_color:t.active_choice_background_color,rounded_corners_radius:t.active_choice_rounded_corners_radius,border:{width:t.active_choice_border_width,color:t.font_color}})),l(c,r.text_lines,a,t,{},!1))}is_hover(e){var t=this.params.minimum_bounding_zone();return e.offsetX>=t.left&&e.offsetX<=t.right&&e.offsetY>=t.top&&e.offsetY<=t.bottom}}t.exports=s},{"../../lib/shape-tools":9,"../../lib/text-tools":10,"./ui-component":33}],25:[function(e,t,n){"use strict";class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("ChoicesPanelCpt")}draw(){super.draw(),this.params.is_visible()?(this.clear_bounding_zone({clear_color:this.params.invisible_clear_color}),this.clear_bounding_zone({use_bounding_zone:this.params.minimum_bounding_zone()}),this.draw_children()):this.clear_bounding_zone({clear_color:this.params.invisible_clear_color})}}t.exports=s},{"./ui-component":33}],26:[function(e,t,n){"use strict";class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("ClosingIconCpt")}draw(){super.draw(),this.clear_bounding_zone();var{color:e,center:t,radius:n=5,line_width:s=2,background_color:i="rgba(0,0,0,0)"}=this.params;const o=window.mentalo_drawing_context;var a=n/1.5,a={left:t.x-n+a,right:t.x+n-a,top:t.y-n+a,bottom:t.y+n-a};o.fillStyle=i,o.beginPath(),o.arc(t.x,t.y,n,0,2*Math.PI),o.fill(),o.strokeStyle=e,o.lineWidth=s,o.beginPath(),o.arc(t.x,t.y,n,0,2*Math.PI),o.stroke(),o.beginPath(),o.moveTo(a.left,a.top),o.lineTo(a.right,a.bottom),o.moveTo(a.left,a.bottom),o.lineTo(a.right,a.top),o.stroke()}}t.exports=s},{"./ui-component":33}],27:[function(e,t,n){"use strict";class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("GameObjectCpt")}draw(){super.draw();const{position:e,dimensions:t,image:n,is_in_inventory:s}=this.params;if(!s()){const i=window.mentalo_drawing_context;i.drawImage(n,0,0,n.width,n.height,e.x,e.y,t.w,t.h),this.state.draw_border&&(i.lineWidth=1,i.strokeStyle="rgba(180, 180, 180, 0.5)",i.strokeRect(e.x-5,e.y-5,t.w+10,t.h+10))}}}t.exports=s},{"./ui-component":33}],28:[function(e,t,n){"use strict";class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("InventoryCpt")}draw(){super.draw(),this.params.is_visible()?(this.clear_bounding_zone(),this.draw_children()):this.clear_bounding_zone({clear_color:this.params.invisible_clear_color})}}t.exports=s},{"./ui-component":33}],29:[function(e,t,n){"use strict";const{draw_rect:c}=e("../../lib/shape-tools");class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("InventoryObjectCpt")}draw(){super.draw();const{get_game_object:e,slot_index:t,settings:n,bounding_zone:s,stroke_color:i}=this.params;var o,a,r=e(t);r&&(o=window.mentalo_drawing_context,a=r.ref.image,c(o,s.left,s.top,s.width,s.height,{fill_image:{src:a,dw:r.dimensions.width,dh:r.dimensions.height,dx:r.position.x,dy:r.position.y},rounded_corners_radius:n.slot_rounded_corner_radius,border:{width:n.slot_border_width,color:i}}),this.state.draw_inventory_close_icon&&this.draw_children())}}t.exports=s},{"../../lib/shape-tools":9,"./ui-component":33}],30:[function(e,t,n){"use strict";const{draw_rect:r}=e("../../lib/shape-tools");class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("InventorySlotCpt")}draw(){super.draw();var{bounding_zone:e,stroke_color:t,settings:n}=this.params,{left:s,top:i,width:o,height:a}=e,e=window.mentalo_drawing_context;r(e,s,i,o,a,{fill_color:"rgba(0,0,0,0)",rounded_corners_radius:n.slot_rounded_corner_radius,border:{width:n.slot_border_width,color:t}}),this.draw_children()}}t.exports=s},{"../../lib/shape-tools":9,"./ui-component":33}],31:[function(e,t,n){"use strict";class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("SceneAnimationCpt"),this.framecount=0,this.state.text_box_visible=!0}set_text_box_visibility(e){this.state.text_box_visible=e}update_framecount(){this.framecount=this.framecount+1<=Number.MAX_SAFE_INTEGER?this.framecount+1:0}draw(){super.draw();const{bounding_zone:e,get_animation:t,next_frame_ready:n}=this.params;if(n()){this.clear_bounding_zone();const _=window.mentalo_drawing_context,h=t();h.update_frame(this.framecount);var s=h.dimensions,i=s.width,o=s.height,a=h.frame*i,r=e.width,c=e.height;h.canvas_precalc||(h.canvas_precalc={dx:e.left,dy:e.top,dw:r,dh:c},r/c<i/o?(h.canvas_precalc.dw=r,h.canvas_precalc.dh=r*(o/i),h.canvas_precalc.dy=e.top+(c-h.canvas_precalc.dh)/2):i/o<r/c?(h.canvas_precalc.dh=c,h.canvas_precalc.dw=c*(i/o),h.canvas_precalc.dx=e.left+(r-h.canvas_precalc.dw)/2):(h.canvas_precalc.dh=c,h.canvas_precalc.dw=r));var{dx:l,dy:s,dw:c,dh:r}=h.canvas_precalc;_.drawImage(h.image,a,0,i,o,l,s,c,r),this.draw_children(),this.update_framecount()}}}t.exports=s},{"./ui-component":33}],32:[function(e,t,n){"use strict";const{draw_text_in_bounds:r}=e("../../lib/text-tools"),{get_canvas_char_size:_}=e("../../lib/font-tools"),{draw_rect:c}=e("../../lib/shape-tools");class s extends e("./ui-component"){constructor(e){super(e),this.set_str_id("TextBoxCpt"),this.state.text={}}init(){const{text:e,settings:t,bounding_zone:n}=this.params;var s=_(window.mentalo_drawing_context,t),i=s.text_line_height,o=3*s.width;const a=(n.width-o)/s.width,r=[],c=e.split("\n");c.forEach(e=>{const t=[""];let n=0;for(const s of e.split(" "))(t[n]+s+" ").length>a&&(t.push(""),n++),t[n]+=s+" ";t.forEach(e=>r.push(e))});o=r.length*i-s.interline_height,s=Object.assign({...n},{height:n.height+o,top:n.top-o});this.state.text={lines:r,line_height:i,bounding_zone:s};const l=this.children[0];s=l.params.bounding_zone,Object.assign(s,{top:s.top-o,bottom:s.bottom-o});l.params.center.y-=o,this.state.initialized=!0}draw(){var e,t,n,s,i,o,a;this.params.get_visibility_state()&&(super.draw(),{settings:e}=this.params,this.state.initialized||this.init(),t=window.mentalo_drawing_context,{lines:n,bounding_zone:s}=this.state.text,i={x:s.left,y:s.top},o=s.width,a=s.height,c(t,i.x,i.y,o,a,{fill_color:e.background_color,rounded_corners_radius:e.rounded_corners_radius,border:{width:e.border_width,color:e.font_color}}),r(t,n,s,e,this.state.text,!0)&&this.draw_children())}}t.exports=s},{"../../lib/font-tools":7,"../../lib/shape-tools":9,"../../lib/text-tools":10,"./ui-component":33}],33:[function(e,t,n){"use strict";const{draw_rect:a}=e("../../lib/shape-tools");t.exports=class{constructor(e){this.set_str_id(),this.params=e,this.params.event_listeners=this.params.event_listeners||[],this.params.get_children=this.params.get_children||(()=>[]),this.params.is_visible=this.params.is_visible||(()=>!0),this.children=[],this.children_set=!1,this.set_children(),this.state={event_listeners_initialized:!1},this.init_event_listeners()}set_str_id(e=this.constructor.name){this.str_id=e}clear_bounding_zone(e={}){let{bounding_zone:t}=this.params;var{use_bounding_zone:n}=e;t=n||t;var{left:s,top:i,width:o,height:n}=t;a(window.mentalo_drawing_context,s,i,o,n,{fill_color:e.clear_color||t.clear_color||"rgba(0,0,0,0)"})}clear_event_listeners(e={recursive:!0}){this.params.event_listeners.forEach(e=>{window.removeEventListener(e.event_type,e.listener)}),e.recursive&&this.children.forEach(e=>{e.clear_event_listeners()}),this.state.event_listeners_initialized=!1}clear_children(e){const{exclude:t}=e,n=[];this.children.forEach(e=>{t.includes(e.str_id)&&n.push(e)}),this.children=n,this.children_set=!1}set_children(){this.children=this.children.concat(this.params.get_children().filter(t=>{return!this.children.find(e=>e.str_id===t.str_id)})),this.children_set=!0}add_event_listener(e){e=this.params.event_listeners.push(e),e=this.params.event_listeners[e-1];window.addEventListener(e.event_type,e.listener)}init_event_listeners(){const{event_listeners:e}=this.params;e.forEach(e=>this.add_event_listener(e)),this.state.event_listeners_initialized=!0}draw_children(){this.children_set||this.set_children(),this.children.forEach(e=>e.draw())}draw(){this.state.event_listeners_initialized||this.init_event_listeners()}}},{"../../lib/shape-tools":9}],34:[function(e,t,n){"use strict";const{draw_text_in_bounds:l}=e("../../lib/text-tools"),{draw_rect:_}=e("../../lib/shape-tools");class s extends e("./ui-component"){constructor(e){super(e),this.state={text:{}},this.set_str_id("UserErrorPopup")}draw(){super.draw();var{text_lines:e,bounding_zone:t,padding:n,settings:s,modal_bounds:i,clear_modal_color:o}=this.params;this.clear_bounding_zone({use_bounding_zone:i,clear_color:o});var a=window.mentalo_drawing_context,{left:r,top:c,width:i,height:o}=t;_(a,r,c,i,o,{fill_color:s.background_color,rounded_corners_radius:s.rounded_corners_radius,border:{width:s.border_width,color:s.font_color}}),l(a,e,t,Object.assign({...s},n),this.state.text,!!this.params.stream_text)&&this.draw_children()}}t.exports=s},{"../../lib/shape-tools":9,"../../lib/text-tools":10,"./ui-component":33}],35:[function(e,t,n){const s=["en","fr","es"],i={"Some scenes have an empty image, game cannot be loaded":{fr:"Certaines scènes ont une image vide, le jeu ne peut pas être chargé",es:"Algunas escenas tienen una imagen vacía, el juego no se puede cargar"},"Destination scene has not been set.":{fr:"La scène de destination n'a pas été définie.",es:"La escena de destino no ha sido definida."},"Next scene has not been set.":{fr:"La scène suivante n'a pas été définie",es:"La siguiente escena no ha sido definida."}};t.exports={get_translated:function(e,t){return i[e]&&"en"!==t&&s.includes(t)?i[e][t]:e},supported_locales:s}},{}],36:[function(e,t,n){"use strict";t.exports={register_key:"objectToHtmlRender",register(e){e=e||this.register_key;window[e]=this},setRenderCycleRoot(e){this.renderCycleRoot=e},event_name:"objtohtml-render-cycle",setEventName(e){this.event_name=e},objectToHtml(t){if(!t)return document.createElement("span");const n=this.objectToHtml.bind(this),{tag:e,xmlns:s}=t,i=void 0!==s?document.createElementNS(s,e):document.createElement(e),o=["tag","contents","style_rules","state","xmlns"];return Object.keys(t).filter(e=>!o.includes(e)).forEach(e=>{switch(e){case"class":i.classList.add(...t[e].split(" ").filter(e=>""!==e));break;case"on_render":t.id||(i.id=`${btoa(JSON.stringify(t).slice(0,127)).replace(/\=/g,"")}${window.performance.now()}`),"function"!=typeof t.on_render?console.error("The on_render attribute must be a function"):this.attach_on_render_callback(i,t.on_render);break;default:void 0!==s?i.setAttributeNS(null,e,t[e]):i[e]=t[e]}}),t.contents&&"string"==typeof t.contents?i.innerHTML=t.contents:t.contents&&0<t.contents.length&&t.contents.forEach(e=>{switch(typeof e){case"string":i.innerHTML=e;break;case"object":void 0!==s&&(e=Object.assign(e,{xmlns:s})),i.appendChild(n(e))}}),t.style_rules&&Object.keys(t.style_rules).forEach(e=>{i.style[e]=t.style_rules[e]}),i},on_render_callbacks:[],attach_on_render_callback(t,n){var e={callback:e=>{e.detail.outputNode!==t&&!e.detail.outputNode.querySelector(`#${t.id}`)||(n(t),-1===(e=this.on_render_callbacks.indexOf(this.on_render_callbacks.find(e=>e.node===t)))?console.warn("A callback was registered for node with id "+t.id+" but callbacck handler is undefined."):(window.removeEventListener(this.event_name,this.on_render_callbacks[e].callback),this.on_render_callbacks.splice(e,1)))},node:t},e=this.on_render_callbacks.push(e);window.addEventListener(this.event_name,this.on_render_callbacks[e-1].callback)},renderCycle:function(){var e,e=document.getElementsByTagName("main")[0]||(e=document.createElement("main"),document.body.appendChild(e),e);this.subRender(this.renderCycleRoot.render(),e,{mode:"replace"})},subRender(e,t,n={mode:"append"}){let s=null;var i=()=>(s=this.objectToHtml(e),s);switch(n.mode){case"append":t.appendChild(i());break;case"override":t.innerHTML="",t.appendChild(i());break;case"insert-before":t.insertBefore(i(),t.childNodes[n.insertIndex]);break;case"adjacent":t.insertAdjacentHTML(n.insertLocation,i());break;case"replace":t.parentNode.replaceChild(i(),t);break;case"remove":t.remove()}var o=this.event_name,o=new CustomEvent(o,{detail:{inputObject:e,outputNode:s,insertOptions:n,targetNode:t}});window.dispatchEvent(o)}}},{}],37:[function(e,t,n){"use strict";t.exports=class{constructor(e){this.props=e,this.id=this.props.images.join("").replace(/\s\./g),this.state={showImageIndex:0},this.RUN_INTERVAL=5e3,1<this.props.images.length&&this.run()}run(){this.runningInterval=setInterval(()=>{var{showImageIndex:e}=this.state,{images:t}=this.props;this.state.showImageIndex=e<t.length-1?++e:0,this.refreshImage()},this.RUN_INTERVAL)}setImageIndex(e){clearInterval(this.runningInterval),this.state.showImageIndex=e,this.refreshImage()}refreshImage(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}render(){const{showImageIndex:n}=this.state,{images:e}=this.props;return{tag:"div",id:this.id,class:"image-carousel",contents:[{tag:"img",property:"image",alt:`image carousel ${e[n].replace(/\.[A-Za-z]+/,"")}`,src:e[n]},1<e.length&&{tag:"div",class:"carousel-bullets",contents:e.map((e,t)=>{return{tag:"span",class:`bullet ${n===t?"active":""}`,onclick:this.setImageIndex.bind(this,t)}})}]}}}},{}],38:[function(e,t,n){"use strict";const{fetch_json_or_error_text:s}=e("./fetch");t.exports={loadArticles:function(e,t){return s(`/articles/${e}/${t}`)},getArticleBody:function(e){return e.replaceAll("\n","<br/>")},getArticleDate:function(e){return`${e.getDate()}-${e.getMonth()+1}-${e.getFullYear()}`}}},{"./fetch":39}],39:[function(e,t,n){"use strict";t.exports={fetchjson:function(e){return new Promise((t,n)=>{fetch(e).then(e=>e.json()).then(e=>t(e)).catch(e=>n(e))})},fetchtext:function(e){return new Promise((t,n)=>{fetch(e).then(e=>e.text()).then(e=>t(e)).catch(e=>n(e))})},fetch_json_or_error_text:async function(e,s={}){return new Promise((t,n)=>{fetch(e,s).then(async e=>{400<=e.status&&e.status<600?n(await e.text()):t(await e.json())})})}}},{}],40:[function(e,t,n){"use strict";const s=e("ks-cheap-translator"),{translations_url:i}=e("../../constants");t.exports=class{constructor(e){Object.assign(this,e),this.id||(this.id="webpage-"+performance.now()),s.init({translations_url:i,supported_languages:["fr","en"]}).then(this.refresh_all.bind(this))}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}refresh_all(){obj2htm.renderCycle()}}},{"../../constants":3,"ks-cheap-translator":4}],41:[function(e,t,n){"use strict";const{images_url:i}=e("../../../../../admin-frontend/src/constants"),{data_url:s}=e("../../../../constants"),o=e("../../../generic-components/image-carousel"),{getArticleBody:a}=e("../../../lib/article-utils"),{fetch_json_or_error_text:r}=e("../../../lib/fetch"),{MentaloEngine:c}=e("mentalo-engine"),l=e("ks-cheap-translator"),_=l.trad.bind(l);t.exports=class{constructor(e){this.props=e,this.parse_body()}parse_body(){let e=a(this.props.body);var t=/\[PLAY_BUTTON\s\{.+\}\]/g;const n=e.match(t);n&&(this.build_play_button(JSON.parse(n[0].replace(/[\[\]PLAY_BUTTON\s]/g,""))),e=e.replace(t,"")),this.body=e}build_play_button(e){this.render_play_button={tag:"button",class:"play-button",contents:_("Jouer"),onclick:this.handle_click_play.bind(this,e.filename,e.engine)}}load_and_run_mentalo_game(e,t){const n=t.innerHTML;t.innerHTML="Loading ...",t.style.pointerEvents="none",r(`${s}/${e}`).then(e=>{const t=document.createElement("div");t.style.position="fixed",t.style.top=0,t.style.left=0,t.style.right=0,t.style.bottom=0,t.style.zIndex=10,t.style.display="flex",t.style.justifyContent="center",t.style.alignItems="center",t.id="kuadrado-tmp-game-player-container",document.body.appendChild(t),document.body.style.overflow="hidden";const n=new c({game_data:e,fullscreen:!0,frame_rate:30,container:t,on_quit_game:()=>{t.remove(),document.body.style.overflow="visible"}});n.init(),n.run_game()}).catch(e=>console.log(e)).finally(()=>{t.innerHTML=n,t.style.pointerEvents="unset"})}handle_click_play(e,t,n){"mentalo"===t?this.load_and_run_mentalo_game(e,n.target):console.log("Error, unkown engine")}render(){const{title:e,subtitle:t,images:n,details:s}=this.props;return{tag:"article",typeof:"VideoGame",additionalType:"Article",class:"game-article",contents:[{tag:"h2",property:"name",class:"game-title",contents:e},{tag:"div",class:"game-banner",contents:[{tag:"img",class:"pixelated",src:`${i}/${n[0]}`}]},{tag:"h3",class:"game-subtitle",contents:t,property:"alternativeHeadline"},{tag:"div",class:"game-description",property:"description",contents:[{tag:"p",style_rules:{margin:0},contents:this.body}].concat(this.render_play_button?[this.render_play_button]:[])},new o({images:n.map(e=>`${i}/${e}`)}).render(),0<s.length&&{tag:"div",class:"article-details",contents:[{tag:"h2",contents:"Details"},{tag:"ul",class:"details-list",contents:s.map(e=>({tag:"li",class:"detail",contents:[{tag:"label",contents:e.label},{tag:"div",class:"detail-value",contents:e.value}]}))}]}]}}}},{"../../../../../admin-frontend/src/constants":1,"../../../../constants":3,"../../../generic-components/image-carousel":37,"../../../lib/article-utils":38,"../../../lib/fetch":39,"ks-cheap-translator":4,"mentalo-engine":5}],42:[function(e,t,n){"use strict";const{loadArticles:s}=e("../../../lib/article-utils"),i=e("./game-article"),o=e("ks-cheap-translator");t.exports=class{constructor(e){this.props=e,this.state={articles:[]},this.id="game-articles-section",this.loadArticles()}loadArticles(){s("games",o.locale).then(e=>{this.state.articles=e,this.refresh()}).catch(e=>console.log(e))}renderPlaceholder(){return{tag:"article",class:"placeholder",contents:[{tag:"div"},{tag:"div"}]}}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}render(){const{articles:e}=this.state;return{tag:"section",class:"game-articles page-contents-center",id:this.id,contents:0<e.length?e.map(e=>new i({...e}).render()):[this.renderPlaceholder()]}}}},{"../../../lib/article-utils":38,"./game-article":41,"ks-cheap-translator":4}],43:[function(e,t,n){"use strict";const{images_url:s}=e("../../../constants");var i=e("../../lib/web-page");const o=e("./components/game-articles"),a=e("ks-cheap-translator"),r=a.trad.bind(a);class c extends i{render(){return{tag:"div",id:"games-page",contents:[{tag:"div",class:"page-header logo-left",contents:[{tag:"div",class:"page-contents-center grid-wrapper",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"image game controller",src:`${s}/game_controller.svg`}]},{tag:"h1",contents:r("Jeux")},{tag:"p",contents:r("games-page-intro")}]}]},(new o).render()]}}}t.exports=c},{"../../../constants":3,"../../lib/web-page":40,"./components/game-articles":42,"ks-cheap-translator":4}],44:[function(e,t,n){"use strict";const s=e("../../run-page");e=e("./games");s(e)},{"../../run-page":45,"./games":43}],45:[function(e,t,n){"use strict";const s=e("object-to-html-renderer"),i=e("./template/template");t.exports=function(e){e=new i({page:new e});s.register("obj2htm"),obj2htm.setRenderCycleRoot(e),obj2htm.renderCycle()}},{"./template/template":47,"object-to-html-renderer":36}],46:[function(e,t,n){"use strict";const{images_url:s}=e("../../../constants"),a=e("ks-cheap-translator"),r=a.trad.bind(a),i=[{url:"/games/",text:"Jeux"},{url:"/education/",text:"Pédagogie"},{url:"/software-development/",text:"Software"}];t.exports=class{constructor(){this.initEventHandlers()}handleBurgerClick(){document.getElementById("nav-menu-list").classList.toggle("responsive-show")}initEventHandlers(){window.addEventListener("click",e=>{"nav-menu-list"===e.target.id||e.target.classList.contains("burger")||e.target.parentNode.classList.contains("burger")||document.getElementById("nav-menu-list").classList.remove("responsive-show")})}handle_chang_lang(e){a.update_translations(e).then(()=>{obj2htm.renderCycle()}).catch(e=>console.log(e))}renderHome(){return{tag:"div",class:"home",contents:[{tag:"a",href:"/",contents:[{tag:"img",alt:"Logo Kuadrado",src:`${s}/logo_kuadrado.svg`},{tag:"img",alt:"Kuadrado Software",class:"logo-text",src:`${s}/logo_kuadrado_txt.svg`}]}]}}renderMenu(e,i=!1,o=""){return{tag:"ul",id:"nav-menu-list",class:i?"submenu":"",contents:e.map(e=>{var{url:t,text:n,submenu:e}=e;const s=`${o}${t}`;return{tag:"li",class:i||window.location.pathname!==s?"":"active",contents:[{tag:"a",href:s,contents:r(n)}].concat(e?[this.renderMenu(e,!0,t)]:[])}}).concat({tag:"li",class:"lang-flags",contents:["fr","en"].map(e=>({tag:"img",src:`${s}/flag-${e}.svg`,class:a.locale===e?"selected":"",onclick:this.handle_chang_lang.bind(this,e)}))})}}renderResponsiveBurger(){return{tag:"div",class:"burger",onclick:this.handleBurgerClick.bind(this),contents:[{tag:"span",contents:"···"}]}}render(){return{tag:"nav",contents:[this.renderHome(),this.renderResponsiveBurger(),this.renderMenu(i)]}}}},{"../../../constants":3,"ks-cheap-translator":4}],47:[function(e,t,n){"use strict";const{in_construction:s}=e("../../config"),{images_url:i}=e("../../constants"),o=e("./components/navbar"),a=e("ks-cheap-translator"),r=a.trad.bind(a);t.exports=class{constructor(e){this.props=e}render(){return{tag:"main",contents:[{tag:"header",contents:[(new o).render()]},s&&{tag:"section",class:"warning-banner",contents:[{tag:"strong",class:"page-contents-center",contents:r("Site en construction ...")}]},{tag:"section",id:"page-container",contents:[this.props.page.render()]},{tag:"footer",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"logo Kuadrado",src:`${i}/logo_kuadrado.svg`},{tag:"img",class:"text-logo",alt:"Kuadrado Software",src:`${i}/logo_kuadrado_txt.svg`}]},{tag:"span",contents:"32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France"},{tag:"div",contents:[{tag:"strong",contents:"<blue>Contact : </blue>"},{tag:"a",href:"mailto:contact@kuadrado-software.fr",contents:"contact@kuadrado-software.fr"}]},{tag:"div",class:"social",contents:[{tag:"strong",contents:`<blue>${r("Sur les réseaux")} : </blue>`},{tag:"a",href:"https://www.linkedin.com/company/kuadrado-software",target:"_blank",contents:"in",title:"Linkedin"},{tag:"a",href:"https://mastodon.gamedev.place/@kuadrado_software",target:"_blank",contents:"m",title:"Mastodon"}]},{tag:"span",contents:`Copyleft 🄯 ${(new Date).getFullYear()} Kuadrado Software | 
+                                ${r("kuadrado-footer-copyleft")}`},{tag:"div",contents:[{tag:"span",contents:r("Ce site web est")+" "},{tag:"a",target:"_blank",style_rules:{fontWeight:"bold"},href:"https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",contents:"OPEN SOURCE"}]}]}]}}}},{"../../config":2,"../../constants":3,"./components/navbar":46,"ks-cheap-translator":4}]},{},[44]);
\ No newline at end of file
diff --git a/public/main.js b/public/main.js
index 1e3f2ac561a68051d057a398bb632213e26b0cca..f94c5f58efe6a208a5dac116f4a1d5d62b498591 100644
--- a/public/main.js
+++ b/public/main.js
@@ -1,846 +1,2 @@
-(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
-module.exports = {
-    build: {
-        protected_dirs: ["assets", "style", "views", "standard"],
-        default_meta_keys: ["title", "description", "image", "open_graph", "json_ld"],
-    },
-};
-
-},{}],2:[function(require,module,exports){
-module.exports = {
-    images_url: `/assets/images`,
-    data_url: `/assets/data`,
-    translations_url: "/assets/translations"
-};
-
-},{}],3:[function(require,module,exports){
-"use strict";
-/**
- * A tiny library to handle text translation.
- */
-
-/**
- * init parameter object type
- * @typedef TranslatorParam
- * @property {String[]} supported_languages - DEFAULT: ["en"] - An array of ISO 639 lowercase language codes
- * @property {String} locale ISO 639 lowercase language code - DEFAULT: "en"
- *
- * @property {Boolean} use_url_locale_fragment - DEFAULT: true -
- * If true, the 1st fragment of the window.location url will be parsed as a language code
- * Example:
- * window.location
- * > https://example.com/en/some-page...
- * then the /en/ part of the url will be taken in priority and locale will be set to "en".
- * window.location
- * > https://example.com/some-page...
- * No locale fragment will be found so locale will not be set from url fragment.
- * window.location
- * > https://example.com/some-page/en
- * Doesn't work, locale fragment must be the first framgment
- *
- * @property {String} local_storage_key  - DEFAULT: "translator-prefered-language"
- *  The key used to saved the current locale into local storage
- *
- * @property {String} translations_url - REQUIRED - the url of the directory containing the static json files for translations
- * Translations files are expected to be named with their corresponding locale code.
- * Example:
- * if supported_languages is set to ["en", "fr", "it"]
- * and translations_url is set to "https://example.com/translations/""
- * Then the expected translations files are
- * https://example.com/translations/en.json
- * https://example.com/translations/fr.json
- * https://example.com/translations/it.json
- * The json resources must simple key value maps, value being the translated text.
- */
-
-module.exports = {
-    locale: "en", // ISO 639 lowercase language code
-    supported_languages: ["en"],
-    translations: {},
-    translations_url: "",
-    use_url_locale_fragment: true,
-    local_storage_key: "translator-prefered-language",
-
-    /**
-     * Initialize the lib with params
-     * @param {TranslatorParam} params 
-     * @returns {Promise}
-     */
-    init(params) {
-        Object.entries(params).forEach(k_v => {
-            const [key, value] = k_v;
-            if ([
-                "supported_languages",
-                "use_url_locale_fragment",
-                "local_storage_key",
-                "translations_url"
-            ].includes(key)) {
-                this[key] = value;
-            }
-        });
-
-        this.translations_url = this.format_translations_url(this.translations_url);
-        this.supported_languages = this.format_supported_languages(this.supported_languages);
-
-        return new Promise((resolve, reject) => {
-            const loc =
-                (() => {// Locale from url priority 1
-                    if (this.use_url_locale_fragment) {
-                        const first_url_fragment = window.location.pathname.substring(1).split("/")[0];
-                        const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-                        return fragment_is_locale ? first_url_fragment : "";
-                    } else {
-                        return "";
-                    }
-                })()
-                || localStorage.getItem(this.local_storage_key) // Locale from storage priority 2
-                || (() => { // locale from navigator priority 3
-                    const navigator_locale = navigator.language.split("-")[0].toLocaleLowerCase();
-                    return this.supported_languages.includes(navigator_locale)
-                        ? navigator_locale
-                        : this.supported_languages[0] // Default if navigator locale is not supported
-                })();
-
-            fetch(`${this.translations_url}${loc}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.locale = loc;
-                    this.translations = response;
-                    resolve();
-                })
-                .catch(err => {
-                    this.locale = "en";
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Return a lowercase string without dash.
-     * If given locale is en-EN, then "en" will be returned.
-     * @param {String} locale 
-     * @returns A lowercase string
-     */
-    format_locale(locale) {
-        return locale.split("-")[0].toLowerCase();
-    },
-
-    /**
-     * Appends a slash at the end of the given string if missing, and returns the url.
-     * @param {String} url 
-     * @returns {String}
-     */
-    format_translations_url(url) {
-        if (url.charAt(url.length - 1) !== "/") {
-            url += "/"
-        }
-        return url;
-    },
-
-    /**
-     * Return the array of language codes formatted as lowsercase language code.
-     * if ["en-EN", "it-IT"]is given, ["en", "it"] will b returned.
-     * @param {String[]} languages_codes 
-     * @returns {String[]}
-     */
-    format_supported_languages(languages_codes) {
-        return languages_codes.map(lc => this.format_locale(lc));
-    },
-
-    /**
-     * Fetches a new set of translations in case the wanted language changes
-     * @param {String} locale A lowercase language code
-     * @returns {Promise}
-     */
-    update_translations(locale) {
-        locale = this.format_locale(locale);
-        return new Promise((resolve, reject) => {
-            fetch(`${this.translations_url}${locale}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.translations = response;
-                    this.locale = locale;
-
-                    localStorage.setItem(this.local_storage_key, locale);
-
-                    const split_path = window.location.pathname.substring(1).split("/");
-                    const first_url_fragment = split_path[0];
-                    const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-
-                    if (fragment_is_locale) {
-                        split_path.splice(0, 1, locale);
-                        const updated_path = split_path.join("/");
-                        window.history.replaceState(null, "", "/" + updated_path);
-                    }
-                    resolve();
-                })
-                .catch(err => {
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Tries to get the translation of the source string, or return the string as it.
-     * @param {String} text The source text to translate
-     * @param {Object} params Some dynamic element to insert in the text
-     * Params can be used if the translated text provide placeholder like {%some_word%}
-     * Example:
-     * translator.trad("Some trad key", {some_word: "a dynamic parameter"})
-     * -> translation for "Some trad key": "A translated text with {%some_word%}"
-     * -> will be return as : "A translated text with a dynamic parameter"
-     * @returns {String}
-     */
-    trad: function (text, params = {}) {
-        text = this.translations[text] || text;
-
-        Object.keys(params).forEach(k => {
-            text = text.replace(`{%${k}%}`, params[k]);
-        });
-
-        return text;
-    }
-};
-},{}],4:[function(require,module,exports){
-"use strict";
-
-module.exports = {
-    register_key: "objectToHtmlRender",
-
-    /**
-     * Register "this" as a window scope accessible variable named by the given key, or default.
-     * @param {String} key 
-     */
-    register(key) {
-        const register_key = key || this.register_key;
-        window[register_key] = this;
-    },
-
-    /**
-     * This must be called before any other method in order to initialize the lib.
-     * It provides the root of the rendering cycle as a Javascript object.
-     * @param {Object} renderCycleRoot A JS component with a render method.
-     */
-    setRenderCycleRoot(renderCycleRoot) {
-        this.renderCycleRoot = renderCycleRoot;
-    },
-
-    event_name: "objtohtml-render-cycle",
-
-    /**
-     * Set a custom event name for the event that is trigger on render cycle.
-     * Default is "objtohtml-render-cycle".
-     * @param {String} evt_name 
-     */
-    setEventName(evt_name) {
-        this.event_name = evt_name;
-    },
-
-    /**
-     * This is the core agorithm that read an javascript Object and convert it into an HTML element.
-     * @param {Object} obj The object representing the html element must be formatted like:
-     * {
-     *      tag: String // The name of the html tag, Any valid html tag should work. div, section, br, ul, li...
-     *      xmlns: String // This can replace the tag key if the element is an element with a namespace URI, for example an <svg> tag.
-     *                      See https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS for more information
-     *      style_rules: Object // a object providing css attributes. The attributes names must be in JS syntax,
-     *                              like maxHeight: "500px", backgrouncColor: "#ff2d56",  margin: 0,  etc.
-     *      contents: Array or String // This reprensents the contents that will be nested in the created html element.
-     *                                   <div>{contents}</div>
-     *                                   The contents can be an array of other objects reprenting elements (with tag, contents, etc)
-     *                                   or it can be a simple string.
-     *      // All other attributes will be parsed as html attributes. They can be anything like onclick, href, onchange, title...
-     *      // or they can also define custom html5 attributes, like data, my_custom_attr or anything.
-     * }
-     * @returns {HTMLElement} The output html node.
-     */
-    objectToHtml(obj) {
-        if (!obj) return document.createElement("span"); // in case of invalid input, don't block the whole process.
-        const objectToHtml = this.objectToHtml.bind(this);
-        const { tag, xmlns } = obj;
-        const node = xmlns !== undefined ? document.createElementNS(xmlns, tag) : document.createElement(tag);
-        const excludeKeys = ["tag", "contents", "style_rules", "state", "xmlns"];
-
-        Object.keys(obj)
-            .filter(attr => !excludeKeys.includes(attr))
-            .forEach(attr => {
-                switch (attr) {
-                    case "class":
-                        node.classList.add(...obj[attr].split(" ").filter(s => s !== ""));
-                        break;
-                    case "on_render":
-                        if (!obj.id) {
-                            node.id = `${btoa(JSON.stringify(obj).slice(0, 127)).replace(/\=/g, '')}${window.performance.now()}`;
-                        }
-                        if (typeof obj.on_render !== "function") {
-                            console.error("The on_render attribute must be a function")
-                        } else {
-                            this.attach_on_render_callback(node, obj.on_render);
-                        }
-                        break;
-                    default:
-                        if (xmlns !== undefined) {
-                            node.setAttributeNS(null, attr, obj[attr])
-                        } else {
-                            node[attr] = obj[attr];
-                        }
-                }
-            });
-        if (obj.contents && typeof obj.contents === "string") {
-            node.innerHTML = obj.contents;
-        } else {
-            obj.contents &&
-                obj.contents.length > 0 &&
-                obj.contents.forEach(el => {
-                    switch (typeof el) {
-                        case "string":
-                            node.innerHTML = el;
-                            break;
-                        case "object":
-                            if (xmlns !== undefined) {
-                                el = Object.assign(el, { xmlns })
-                            }
-                            node.appendChild(objectToHtml(el));
-                            break;
-                    }
-                });
-        }
-
-        if (obj.style_rules) {
-            Object.keys(obj.style_rules).forEach(rule => {
-                node.style[rule] = obj.style_rules[rule];
-            });
-        }
-
-        return node;
-    },
-
-    on_render_callbacks: [],
-
-    /**
-     * This is called if the on_render attribute of a component is set.
-     * @param {HTMLElement} node The created html element
-     * @param {Function} callback The callback defined in the js component to render
-     */
-    attach_on_render_callback(node, callback) {
-        const callback_handler = {
-            callback: e => {
-                if (e.detail.outputNode === node || e.detail.outputNode.querySelector(`#${node.id}`)) {
-                    callback(node);
-                    const handler_index = this.on_render_callbacks.indexOf((this.on_render_callbacks.find(cb => cb.node === node)));
-                    if (handler_index === -1) {
-                        console.warn("A callback was registered for node with id " + node.id + " but callbacck handler is undefined.")
-                    } else {
-                        window.removeEventListener(this.event_name, this.on_render_callbacks[handler_index].callback)
-                        this.on_render_callbacks.splice(handler_index, 1);
-                    }
-                }
-            },
-            node,
-        };
-
-        const len = this.on_render_callbacks.push(callback_handler);
-        window.addEventListener(this.event_name, this.on_render_callbacks[len - 1].callback);
-    },
-
-    /**
-     * If a main element exists in the html document, it will be used as rendering root.
-     * If not, it will be created and inserted.
-     */
-    renderCycle: function () {
-        const main_elmt = document.getElementsByTagName("main")[0] || (function () {
-            const created_main = document.createElement("main");
-            document.body.appendChild(created_main);
-            return created_main;
-        })();
-
-        this.subRender(this.renderCycleRoot.render(), main_elmt, { mode: "replace" });
-    },
-
-    /**
-     * This method behaves like the renderCycle() method, but rather that starting the rendering cycle from the root component,
-    * it can start from any component of the tree. The root component must be given as the first argument, the second argument be
-    * be a valid html element in the dom and will be used as the insertion target.
-     * @param {Object} object An object providing a render method returning an object representation of the html to insert
-     * @param {HTMLElement} htmlNode The htlm element to update
-     * @param {Object} options can be used the define the insertion mode, default is set to "append" and can be set to "override",
-         * "insert-before" (must be defined along with an insertIndex key (integer)),
-         * "adjacent" (must be defined along with an insertLocation key (String)), "replace" or "remove".
-         * In case of "remove", the first argument "object" is not used and can be set to null, undefined or {}...
-     */
-    subRender(object, htmlNode, options = { mode: "append" }) {
-        let outputNode = null;
-
-        const get_insert = () => {
-            outputNode = this.objectToHtml(object);
-            return outputNode;
-        };
-
-        switch (options.mode) {
-            case "append":
-                htmlNode.appendChild(get_insert());
-                break;
-            case "override":
-                htmlNode.innerHTML = "";
-                htmlNode.appendChild(get_insert());
-                break;
-            case "insert-before":
-                htmlNode.insertBefore(get_insert(), htmlNode.childNodes[options.insertIndex]);
-                break;
-            case "adjacent":
-                /**
-                 * options.insertLocation must be one of:
-                 *
-                 * afterbegin
-                 * afterend
-                 * beforebegin
-                 * beforeend
-                 */
-                htmlNode.insertAdjacentHTML(options.insertLocation, get_insert());
-                break;
-            case "replace":
-                htmlNode.parentNode.replaceChild(get_insert(), htmlNode);
-                break;
-            case "remove":
-                htmlNode.remove();
-                break;
-        }
-        const evt_name = this.event_name;
-        const event = new CustomEvent(evt_name, {
-            detail: {
-                inputObject: object,
-                outputNode,
-                insertOptions: options,
-                targetNode: htmlNode,
-            }
-        });
-
-        window.dispatchEvent(event);
-    },
-};
-},{}],5:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../constants");
-
-class ThemeCard {
-    constructor(props) {
-        this.props = props;
-    }
-
-    render() {
-        return {
-            tag: "a",
-            class: "theme-card",
-            href: this.props.href,
-            contents: [
-                {
-                    tag: "div",
-                    class: "card-img",
-                    contents: [{ tag: "img", alt: `thematic image ${this.props.img.replace(/\.[A-Za-z]+/, "")}`, src: `${images_url}/${this.props.img}` }],
-                },
-                {
-                    tag: "div",
-                    class: "card-title",
-                    contents: [{ tag: "h2", class: "section-title", contents: this.props.title }],
-                },
-                {
-                    tag: "div",
-                    class: "card-description",
-                    contents: [{ tag: "p", contents: this.props.description }],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = ThemeCard;
-
-},{"../../constants":2}],6:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../constants");
-const ThemeCard = require("./home-page-components/theme-card");
-const WebPage = require("./lib/web-page");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-class HomePage extends WebPage {
-    constructor() {
-        super({ id: "home-page" });
-    }
-
-    render() {
-        return {
-            tag: "div",
-            id: this.id,
-            contents: [
-                {
-                    tag: "div",
-                    class: "page-header",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "big-logo page-contents-center",
-                            contents: [
-                                {
-                                    tag: "img",
-                                    alt: "logo Kuadrado",
-                                    src: `${images_url}/logo_kuadrado.svg`,
-                                },
-                                {
-                                    tag: "img",
-                                    class: "logo-text",
-                                    alt: "Kuadrado",
-                                    src: `${images_url}/logo_kuadrado_txt.svg`,
-                                },
-                            ],
-                        },
-                        { tag: "h1", contents: "Kuadrado Software", class: "page-contents-center" },
-                        {
-                            tag: "p",
-                            class: "page-contents-center",
-                            contents: t("kuadrado-home-description"),
-                        },
-                        {
-                            tag: "ul",
-                            class: "philo-bubbles",
-                            contents: [t("Simplicité"), t("Légèreté"), t("Écologie")].map(word => {
-                                return {
-                                    tag: "li",
-                                    contents: [{ tag: "span", contents: word }],
-                                };
-                            }),
-                        },
-                    ],
-                },
-                {
-                    tag: "section",
-                    class: "page-contents-center poles",
-                    contents: [
-                        {
-                            title: t("Jeux"),
-                            img: "game_controller.svg",
-                            href: "/games/",
-                            description:
-                                t("games-description"),
-                        },
-                        {
-                            title: t("Pédagogie"),
-                            img: "brain.svg",
-                            href: "/education/",
-                            description: t("education-description"),
-                        },
-                        {
-                            title: "Software",
-                            img: "meca_proc.svg",
-                            href: "/software-development/",
-                            description: t("software-description"),
-                        },
-                    ].map(cardProps => new ThemeCard(cardProps).render()),
-                },
-            ],
-        };
-    }
-}
-
-module.exports = HomePage;
-
-},{"../constants":2,"./home-page-components/theme-card":5,"./lib/web-page":7,"ks-cheap-translator":3}],7:[function(require,module,exports){
-"use strict";
-const translator = require("ks-cheap-translator");
-const { translations_url } = require("../../constants");
-
-class WebPage {
-    constructor(args) {
-        Object.assign(this, args);
-
-        if (!this.id) {
-            this.id = "webpage-" + performance.now();
-        }
-
-        translator.init({
-            translations_url,
-            supported_languages: ["fr", "en"],
-        }).then(this.refresh_all.bind(this));
-    }
-
-    refresh() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), { mode: "replace" })
-    }
-
-    refresh_all() {
-        obj2htm.renderCycle()
-    }
-}
-
-module.exports = WebPage;
-},{"../../constants":2,"ks-cheap-translator":3}],8:[function(require,module,exports){
-"use strict";
-
-const HomePage = require("./homepage");
-const runPage = require("./run-page");
-
-runPage(HomePage);
-
-},{"./homepage":6,"./run-page":9}],9:[function(require,module,exports){
-"use strict";
-
-const renderer = require("object-to-html-renderer")
-const Template = require("./template/template");
-
-module.exports = function runPage(PageComponent) {
-    const template = new Template({ page: new PageComponent() });
-    renderer.register("obj2htm")
-    obj2htm.setRenderCycleRoot(template);
-    obj2htm.renderCycle();
-};
-
-},{"./template/template":11,"object-to-html-renderer":4}],10:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-const NAV_MENU_ITEMS = [
-    { url: "/games/", text: "Jeux" },
-    {
-        url: "/education/",
-        text: "Pédagogie",
-    },
-    { url: "/software-development/", text: "Software" }
-];
-
-class NavBar {
-    constructor() {
-        this.initEventHandlers();
-    }
-
-    handleBurgerClick() {
-        document.getElementById("nav-menu-list").classList.toggle("responsive-show");
-    }
-
-    initEventHandlers() {
-        window.addEventListener("click", event => {
-            if (
-                event.target.id !== "nav-menu-list" &&
-                !event.target.classList.contains("burger") &&
-                !event.target.parentNode.classList.contains("burger")
-            ) {
-                document.getElementById("nav-menu-list").classList.remove("responsive-show");
-            }
-        });
-    }
-
-    handle_chang_lang(lang) {
-        translator.update_translations(lang).then(() => {
-            obj2htm.renderCycle();
-        }).catch(err => console.log(err));
-    }
-
-    renderHome() {
-        return {
-            tag: "div",
-            class: "home",
-            contents: [
-                {
-                    tag: "a",
-                    href: "/",
-                    contents: [
-                        {
-                            tag: "img",
-                            alt: "Logo Kuadrado",
-                            src: `${images_url}/logo_kuadrado.svg`,
-                        },
-                        {
-                            tag: "img",
-                            alt: "Kuadrado Software",
-                            class: "logo-text",
-                            src: `${images_url}/logo_kuadrado_txt.svg`,
-                        },
-                    ],
-                },
-            ],
-        };
-    }
-
-    renderMenu(menuItemsArray, isSubmenu = false, parentUrl = "") {
-        return {
-            tag: "ul",
-            id: "nav-menu-list",
-            class: isSubmenu ? "submenu" : "",
-            contents: menuItemsArray.map(item => {
-                const { url, text, submenu } = item;
-                const href = `${parentUrl}${url}`;
-                return {
-                    tag: "li",
-                    class: !isSubmenu && window.location.pathname === href ? "active" : "",
-                    contents: [
-                        {
-                            tag: "a",
-                            href,
-                            contents: t(text),
-                        },
-                    ].concat(submenu ? [this.renderMenu(submenu, true, url)] : []),
-                };
-            }).concat({
-                tag: "li",
-                class: "lang-flags",
-                contents: ["fr", "en"].map(lang => {
-                    return {
-                        tag: "img", src: `${images_url}/flag-${lang}.svg`,
-                        class: translator.locale === lang ? "selected" : "",
-                        onclick: this.handle_chang_lang.bind(this, lang)
-                    }
-                })
-            }),
-        };
-    }
-
-    renderResponsiveBurger() {
-        return {
-            tag: "div",
-            class: "burger",
-            onclick: this.handleBurgerClick.bind(this),
-            contents: [{ tag: "span", contents: "···" }],
-        };
-    }
-
-    render() {
-        return {
-            tag: "nav",
-            contents: [
-                this.renderHome(),
-                this.renderResponsiveBurger(),
-                this.renderMenu(NAV_MENU_ITEMS),
-            ],
-        };
-    }
-}
-
-module.exports = NavBar;
-
-},{"../../../constants":2,"ks-cheap-translator":3}],11:[function(require,module,exports){
-"use strict";
-
-const { in_construction } = require("../../config");
-const { images_url } = require("../../constants");
-const NavBar = require("./components/navbar");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator)
-
-class Template {
-    constructor(props) {
-        this.props = props;
-    }
-    render() {
-        return {
-            tag: "main",
-            contents: [
-                {
-                    tag: "header",
-                    contents: [new NavBar().render()],
-                },
-                in_construction && {
-                    tag: "section",
-                    class: "warning-banner",
-                    contents: [
-                        {
-                            tag: "strong",
-                            class: "page-contents-center",
-                            contents: t("Site en construction ..."),
-                        },
-                    ],
-                },
-                {
-                    tag: "section",
-                    id: "page-container",
-                    contents: [this.props.page.render()],
-                },
-                {
-                    tag: "footer",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "logo",
-                            contents: [
-                                {
-                                    tag: "img",
-                                    alt: `logo Kuadrado`,
-                                    src: `${images_url}/logo_kuadrado.svg`,
-                                },
-                                {
-                                    tag: "img",
-                                    class: "text-logo",
-                                    alt: "Kuadrado Software",
-                                    src: `${images_url}/logo_kuadrado_txt.svg`,
-                                },
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: "32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France",
-                        },
-                        {
-                            tag: "div",
-                            contents: [
-                                { tag: "strong", contents: "<blue>Contact : </blue>" },
-                                {
-                                    tag: "a",
-                                    href: "mailto:contact@kuadrado-software.fr",
-                                    contents: "contact@kuadrado-software.fr",
-                                },
-                            ],
-                        },
-                        {
-                            tag: "div",
-                            class: "social",
-                            contents: [
-                                {
-                                    tag: "strong",
-                                    contents: `<blue>${t("Sur les réseaux")} : </blue>`,
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://www.linkedin.com/company/kuadrado-software",
-                                    target: "_blank",
-                                    contents: "in",
-                                    title: "Linkedin",
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://mastodon.gamedev.place/@kuadrado_software",
-                                    target: "_blank",
-                                    contents: "m",
-                                    title: "Mastodon",
-                                }
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: `Copyleft 🄯 ${new Date()
-                                .getFullYear()} Kuadrado Software | 
-                                ${t("kuadrado-footer-copyleft")}`,
-                        },
-                        {
-                            tag: "div", contents: [
-                                { tag: "span", contents: t("Ce site web est") + " " },
-                                {
-                                    tag: "a", target: "_blank",
-                                    style_rules: { fontWeight: "bold" },
-                                    href: "https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",
-                                    contents: "OPEN SOURCE"
-                                }
-                            ]
-                        }
-                    ],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = Template;
-
-},{"../../config":1,"../../constants":2,"./components/navbar":10,"ks-cheap-translator":3}]},{},[8]);
+!function s(a,r,o){function c(e,t){if(!r[e]){if(!a[e]){var n="function"==typeof require&&require;if(!t&&n)return n(e,!0);if(i)return i(e,!0);throw(n=new Error("Cannot find module '"+e+"'")).code="MODULE_NOT_FOUND",n}n=r[e]={exports:{}},a[e][0].call(n.exports,function(t){return c(a[e][1][t]||t)},n,n.exports,s,a,r,o)}return r[e].exports}for(var i="function"==typeof require&&require,t=0;t<o.length;t++)c(o[t]);return c}({1:[function(t,e,n){e.exports={build:{protected_dirs:["assets","style","views","standard"],default_meta_keys:["title","description","image","open_graph","json_ld"]}}},{}],2:[function(t,e,n){e.exports={images_url:"/assets/images",data_url:"/assets/data",translations_url:"/assets/translations"}},{}],3:[function(t,e,n){"use strict";e.exports={locale:"en",supported_languages:["en"],translations:{},translations_url:"",use_url_locale_fragment:!0,local_storage_key:"translator-prefered-language",init(t){return Object.entries(t).forEach(t=>{var[e,t]=t;["supported_languages","use_url_locale_fragment","local_storage_key","translations_url"].includes(e)&&(this[e]=t)}),this.translations_url=this.format_translations_url(this.translations_url),this.supported_languages=this.format_supported_languages(this.supported_languages),new Promise((e,n)=>{const s=(()=>{if(this.use_url_locale_fragment){var t=window.location.pathname.substring(1).split("/")[0];return this.supported_languages.includes(t)?t:""}return""})()||localStorage.getItem(this.local_storage_key)||(t=navigator.language.split("-")[0].toLocaleLowerCase(),this.supported_languages.includes(t)?t:this.supported_languages[0]);var t;fetch(`${this.translations_url}${s}.json`).then(t=>t.json()).then(t=>{this.locale=s,this.translations=t,e()}).catch(t=>{this.locale="en",n(t)})})},format_locale(t){return t.split("-")[0].toLowerCase()},format_translations_url(t){return"/"!==t.charAt(t.length-1)&&(t+="/"),t},format_supported_languages(t){return t.map(t=>this.format_locale(t))},update_translations(s){return s=this.format_locale(s),new Promise((n,e)=>{fetch(`${this.translations_url}${s}.json`).then(t=>t.json()).then(t=>{this.translations=t,this.locale=s,localStorage.setItem(this.local_storage_key,s);const e=window.location.pathname.substring(1).split("/");t=e[0];this.supported_languages.includes(t)&&(e.splice(0,1,s),t=e.join("/"),window.history.replaceState(null,"","/"+t)),n()}).catch(t=>{e(t)})})},trad:function(e,n={}){return e=this.translations[e]||e,Object.keys(n).forEach(t=>{e=e.replace(`{%${t}%}`,n[t])}),e}}},{}],4:[function(t,e,n){"use strict";e.exports={register_key:"objectToHtmlRender",register(t){t=t||this.register_key;window[t]=this},setRenderCycleRoot(t){this.renderCycleRoot=t},event_name:"objtohtml-render-cycle",setEventName(t){this.event_name=t},objectToHtml(e){if(!e)return document.createElement("span");const n=this.objectToHtml.bind(this),{tag:t,xmlns:s}=e,a=void 0!==s?document.createElementNS(s,t):document.createElement(t),r=["tag","contents","style_rules","state","xmlns"];return Object.keys(e).filter(t=>!r.includes(t)).forEach(t=>{switch(t){case"class":a.classList.add(...e[t].split(" ").filter(t=>""!==t));break;case"on_render":e.id||(a.id=`${btoa(JSON.stringify(e).slice(0,127)).replace(/\=/g,"")}${window.performance.now()}`),"function"!=typeof e.on_render?console.error("The on_render attribute must be a function"):this.attach_on_render_callback(a,e.on_render);break;default:void 0!==s?a.setAttributeNS(null,t,e[t]):a[t]=e[t]}}),e.contents&&"string"==typeof e.contents?a.innerHTML=e.contents:e.contents&&0<e.contents.length&&e.contents.forEach(t=>{switch(typeof t){case"string":a.innerHTML=t;break;case"object":void 0!==s&&(t=Object.assign(t,{xmlns:s})),a.appendChild(n(t))}}),e.style_rules&&Object.keys(e.style_rules).forEach(t=>{a.style[t]=e.style_rules[t]}),a},on_render_callbacks:[],attach_on_render_callback(e,n){var t={callback:t=>{t.detail.outputNode!==e&&!t.detail.outputNode.querySelector(`#${e.id}`)||(n(e),-1===(t=this.on_render_callbacks.indexOf(this.on_render_callbacks.find(t=>t.node===e)))?console.warn("A callback was registered for node with id "+e.id+" but callbacck handler is undefined."):(window.removeEventListener(this.event_name,this.on_render_callbacks[t].callback),this.on_render_callbacks.splice(t,1)))},node:e},t=this.on_render_callbacks.push(t);window.addEventListener(this.event_name,this.on_render_callbacks[t-1].callback)},renderCycle:function(){var t,t=document.getElementsByTagName("main")[0]||(t=document.createElement("main"),document.body.appendChild(t),t);this.subRender(this.renderCycleRoot.render(),t,{mode:"replace"})},subRender(t,e,n={mode:"append"}){let s=null;var a=()=>(s=this.objectToHtml(t),s);switch(n.mode){case"append":e.appendChild(a());break;case"override":e.innerHTML="",e.appendChild(a());break;case"insert-before":e.insertBefore(a(),e.childNodes[n.insertIndex]);break;case"adjacent":e.insertAdjacentHTML(n.insertLocation,a());break;case"replace":e.parentNode.replaceChild(a(),e);break;case"remove":e.remove()}var r=this.event_name,r=new CustomEvent(r,{detail:{inputObject:t,outputNode:s,insertOptions:n,targetNode:e}});window.dispatchEvent(r)}}},{}],5:[function(t,e,n){"use strict";const{images_url:s}=t("../../constants");e.exports=class{constructor(t){this.props=t}render(){return{tag:"a",class:"theme-card",href:this.props.href,contents:[{tag:"div",class:"card-img",contents:[{tag:"img",alt:`thematic image ${this.props.img.replace(/\.[A-Za-z]+/,"")}`,src:`${s}/${this.props.img}`}]},{tag:"div",class:"card-title",contents:[{tag:"h2",class:"section-title",contents:this.props.title}]},{tag:"div",class:"card-description",contents:[{tag:"p",contents:this.props.description}]}]}}}},{"../../constants":2}],6:[function(t,e,n){"use strict";const{images_url:s}=t("../constants"),a=t("./home-page-components/theme-card");var r=t("./lib/web-page");const o=t("ks-cheap-translator"),c=o.trad.bind(o);class i extends r{constructor(){super({id:"home-page"})}render(){return{tag:"div",id:this.id,contents:[{tag:"div",class:"page-header",contents:[{tag:"div",class:"big-logo page-contents-center",contents:[{tag:"img",alt:"logo Kuadrado",src:`${s}/logo_kuadrado.svg`},{tag:"img",class:"logo-text",alt:"Kuadrado",src:`${s}/logo_kuadrado_txt.svg`}]},{tag:"h1",contents:"Kuadrado Software",class:"page-contents-center"},{tag:"p",class:"page-contents-center",contents:c("kuadrado-home-description")},{tag:"ul",class:"philo-bubbles",contents:[c("Simplicité"),c("Légèreté"),c("Écologie")].map(t=>({tag:"li",contents:[{tag:"span",contents:t}]}))}]},{tag:"section",class:"page-contents-center poles",contents:[{title:c("Jeux"),img:"game_controller.svg",href:"/games/",description:c("games-description")},{title:c("Pédagogie"),img:"brain.svg",href:"/education/",description:c("education-description")},{title:"Software",img:"meca_proc.svg",href:"/software-development/",description:c("software-description")}].map(t=>new a(t).render())}]}}}e.exports=i},{"../constants":2,"./home-page-components/theme-card":5,"./lib/web-page":7,"ks-cheap-translator":3}],7:[function(t,e,n){"use strict";const s=t("ks-cheap-translator"),{translations_url:a}=t("../../constants");e.exports=class{constructor(t){Object.assign(this,t),this.id||(this.id="webpage-"+performance.now()),s.init({translations_url:a,supported_languages:["fr","en"]}).then(this.refresh_all.bind(this))}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}refresh_all(){obj2htm.renderCycle()}}},{"../../constants":2,"ks-cheap-translator":3}],8:[function(t,e,n){"use strict";var s=t("./homepage");const a=t("./run-page");a(s)},{"./homepage":6,"./run-page":9}],9:[function(t,e,n){"use strict";const s=t("object-to-html-renderer"),a=t("./template/template");e.exports=function(t){t=new a({page:new t});s.register("obj2htm"),obj2htm.setRenderCycleRoot(t),obj2htm.renderCycle()}},{"./template/template":11,"object-to-html-renderer":4}],10:[function(t,e,n){"use strict";const{images_url:s}=t("../../../constants"),o=t("ks-cheap-translator"),c=o.trad.bind(o),a=[{url:"/games/",text:"Jeux"},{url:"/education/",text:"Pédagogie"},{url:"/software-development/",text:"Software"}];e.exports=class{constructor(){this.initEventHandlers()}handleBurgerClick(){document.getElementById("nav-menu-list").classList.toggle("responsive-show")}initEventHandlers(){window.addEventListener("click",t=>{"nav-menu-list"===t.target.id||t.target.classList.contains("burger")||t.target.parentNode.classList.contains("burger")||document.getElementById("nav-menu-list").classList.remove("responsive-show")})}handle_chang_lang(t){o.update_translations(t).then(()=>{obj2htm.renderCycle()}).catch(t=>console.log(t))}renderHome(){return{tag:"div",class:"home",contents:[{tag:"a",href:"/",contents:[{tag:"img",alt:"Logo Kuadrado",src:`${s}/logo_kuadrado.svg`},{tag:"img",alt:"Kuadrado Software",class:"logo-text",src:`${s}/logo_kuadrado_txt.svg`}]}]}}renderMenu(t,a=!1,r=""){return{tag:"ul",id:"nav-menu-list",class:a?"submenu":"",contents:t.map(t=>{var{url:e,text:n,submenu:t}=t;const s=`${r}${e}`;return{tag:"li",class:a||window.location.pathname!==s?"":"active",contents:[{tag:"a",href:s,contents:c(n)}].concat(t?[this.renderMenu(t,!0,e)]:[])}}).concat({tag:"li",class:"lang-flags",contents:["fr","en"].map(t=>({tag:"img",src:`${s}/flag-${t}.svg`,class:o.locale===t?"selected":"",onclick:this.handle_chang_lang.bind(this,t)}))})}}renderResponsiveBurger(){return{tag:"div",class:"burger",onclick:this.handleBurgerClick.bind(this),contents:[{tag:"span",contents:"···"}]}}render(){return{tag:"nav",contents:[this.renderHome(),this.renderResponsiveBurger(),this.renderMenu(a)]}}}},{"../../../constants":2,"ks-cheap-translator":3}],11:[function(t,e,n){"use strict";const{in_construction:s}=t("../../config"),{images_url:a}=t("../../constants"),r=t("./components/navbar"),o=t("ks-cheap-translator"),c=o.trad.bind(o);e.exports=class{constructor(t){this.props=t}render(){return{tag:"main",contents:[{tag:"header",contents:[(new r).render()]},s&&{tag:"section",class:"warning-banner",contents:[{tag:"strong",class:"page-contents-center",contents:c("Site en construction ...")}]},{tag:"section",id:"page-container",contents:[this.props.page.render()]},{tag:"footer",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"logo Kuadrado",src:`${a}/logo_kuadrado.svg`},{tag:"img",class:"text-logo",alt:"Kuadrado Software",src:`${a}/logo_kuadrado_txt.svg`}]},{tag:"span",contents:"32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France"},{tag:"div",contents:[{tag:"strong",contents:"<blue>Contact : </blue>"},{tag:"a",href:"mailto:contact@kuadrado-software.fr",contents:"contact@kuadrado-software.fr"}]},{tag:"div",class:"social",contents:[{tag:"strong",contents:`<blue>${c("Sur les réseaux")} : </blue>`},{tag:"a",href:"https://www.linkedin.com/company/kuadrado-software",target:"_blank",contents:"in",title:"Linkedin"},{tag:"a",href:"https://mastodon.gamedev.place/@kuadrado_software",target:"_blank",contents:"m",title:"Mastodon"}]},{tag:"span",contents:`Copyleft 🄯 ${(new Date).getFullYear()} Kuadrado Software | 
+                                ${c("kuadrado-footer-copyleft")}`},{tag:"div",contents:[{tag:"span",contents:c("Ce site web est")+" "},{tag:"a",target:"_blank",style_rules:{fontWeight:"bold"},href:"https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",contents:"OPEN SOURCE"}]}]}]}}}},{"../../config":1,"../../constants":2,"./components/navbar":10,"ks-cheap-translator":3}]},{},[8]);
\ No newline at end of file
diff --git a/public/software-development/software-development.js b/public/software-development/software-development.js
index c2ed85689dc4575b4264884d7be01b338cdbbeba..5fa85b30fb499ad1b00531fdda24e5f56809bef2 100644
--- a/public/software-development/software-development.js
+++ b/public/software-development/software-development.js
@@ -1,984 +1,2 @@
-(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
-module.exports = {
-    images_url: "/assets/images"
-}
-},{}],2:[function(require,module,exports){
-module.exports = {
-    build: {
-        protected_dirs: ["assets", "style", "views", "standard"],
-        default_meta_keys: ["title", "description", "image", "open_graph", "json_ld"],
-    },
-};
-
-},{}],3:[function(require,module,exports){
-module.exports = {
-    images_url: `/assets/images`,
-    data_url: `/assets/data`,
-    translations_url: "/assets/translations"
-};
-
-},{}],4:[function(require,module,exports){
-"use strict";
-/**
- * A tiny library to handle text translation.
- */
-
-/**
- * init parameter object type
- * @typedef TranslatorParam
- * @property {String[]} supported_languages - DEFAULT: ["en"] - An array of ISO 639 lowercase language codes
- * @property {String} locale ISO 639 lowercase language code - DEFAULT: "en"
- *
- * @property {Boolean} use_url_locale_fragment - DEFAULT: true -
- * If true, the 1st fragment of the window.location url will be parsed as a language code
- * Example:
- * window.location
- * > https://example.com/en/some-page...
- * then the /en/ part of the url will be taken in priority and locale will be set to "en".
- * window.location
- * > https://example.com/some-page...
- * No locale fragment will be found so locale will not be set from url fragment.
- * window.location
- * > https://example.com/some-page/en
- * Doesn't work, locale fragment must be the first framgment
- *
- * @property {String} local_storage_key  - DEFAULT: "translator-prefered-language"
- *  The key used to saved the current locale into local storage
- *
- * @property {String} translations_url - REQUIRED - the url of the directory containing the static json files for translations
- * Translations files are expected to be named with their corresponding locale code.
- * Example:
- * if supported_languages is set to ["en", "fr", "it"]
- * and translations_url is set to "https://example.com/translations/""
- * Then the expected translations files are
- * https://example.com/translations/en.json
- * https://example.com/translations/fr.json
- * https://example.com/translations/it.json
- * The json resources must simple key value maps, value being the translated text.
- */
-
-module.exports = {
-    locale: "en", // ISO 639 lowercase language code
-    supported_languages: ["en"],
-    translations: {},
-    translations_url: "",
-    use_url_locale_fragment: true,
-    local_storage_key: "translator-prefered-language",
-
-    /**
-     * Initialize the lib with params
-     * @param {TranslatorParam} params 
-     * @returns {Promise}
-     */
-    init(params) {
-        Object.entries(params).forEach(k_v => {
-            const [key, value] = k_v;
-            if ([
-                "supported_languages",
-                "use_url_locale_fragment",
-                "local_storage_key",
-                "translations_url"
-            ].includes(key)) {
-                this[key] = value;
-            }
-        });
-
-        this.translations_url = this.format_translations_url(this.translations_url);
-        this.supported_languages = this.format_supported_languages(this.supported_languages);
-
-        return new Promise((resolve, reject) => {
-            const loc =
-                (() => {// Locale from url priority 1
-                    if (this.use_url_locale_fragment) {
-                        const first_url_fragment = window.location.pathname.substring(1).split("/")[0];
-                        const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-                        return fragment_is_locale ? first_url_fragment : "";
-                    } else {
-                        return "";
-                    }
-                })()
-                || localStorage.getItem(this.local_storage_key) // Locale from storage priority 2
-                || (() => { // locale from navigator priority 3
-                    const navigator_locale = navigator.language.split("-")[0].toLocaleLowerCase();
-                    return this.supported_languages.includes(navigator_locale)
-                        ? navigator_locale
-                        : this.supported_languages[0] // Default if navigator locale is not supported
-                })();
-
-            fetch(`${this.translations_url}${loc}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.locale = loc;
-                    this.translations = response;
-                    resolve();
-                })
-                .catch(err => {
-                    this.locale = "en";
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Return a lowercase string without dash.
-     * If given locale is en-EN, then "en" will be returned.
-     * @param {String} locale 
-     * @returns A lowercase string
-     */
-    format_locale(locale) {
-        return locale.split("-")[0].toLowerCase();
-    },
-
-    /**
-     * Appends a slash at the end of the given string if missing, and returns the url.
-     * @param {String} url 
-     * @returns {String}
-     */
-    format_translations_url(url) {
-        if (url.charAt(url.length - 1) !== "/") {
-            url += "/"
-        }
-        return url;
-    },
-
-    /**
-     * Return the array of language codes formatted as lowsercase language code.
-     * if ["en-EN", "it-IT"]is given, ["en", "it"] will b returned.
-     * @param {String[]} languages_codes 
-     * @returns {String[]}
-     */
-    format_supported_languages(languages_codes) {
-        return languages_codes.map(lc => this.format_locale(lc));
-    },
-
-    /**
-     * Fetches a new set of translations in case the wanted language changes
-     * @param {String} locale A lowercase language code
-     * @returns {Promise}
-     */
-    update_translations(locale) {
-        locale = this.format_locale(locale);
-        return new Promise((resolve, reject) => {
-            fetch(`${this.translations_url}${locale}.json`)
-                .then(response => response.json())
-                .then(response => {
-                    this.translations = response;
-                    this.locale = locale;
-
-                    localStorage.setItem(this.local_storage_key, locale);
-
-                    const split_path = window.location.pathname.substring(1).split("/");
-                    const first_url_fragment = split_path[0];
-                    const fragment_is_locale = this.supported_languages.includes(first_url_fragment);
-
-                    if (fragment_is_locale) {
-                        split_path.splice(0, 1, locale);
-                        const updated_path = split_path.join("/");
-                        window.history.replaceState(null, "", "/" + updated_path);
-                    }
-                    resolve();
-                })
-                .catch(err => {
-                    reject(err);
-                });
-        });
-    },
-
-    /**
-     * Tries to get the translation of the source string, or return the string as it.
-     * @param {String} text The source text to translate
-     * @param {Object} params Some dynamic element to insert in the text
-     * Params can be used if the translated text provide placeholder like {%some_word%}
-     * Example:
-     * translator.trad("Some trad key", {some_word: "a dynamic parameter"})
-     * -> translation for "Some trad key": "A translated text with {%some_word%}"
-     * -> will be return as : "A translated text with a dynamic parameter"
-     * @returns {String}
-     */
-    trad: function (text, params = {}) {
-        text = this.translations[text] || text;
-
-        Object.keys(params).forEach(k => {
-            text = text.replace(`{%${k}%}`, params[k]);
-        });
-
-        return text;
-    }
-};
-},{}],5:[function(require,module,exports){
-"use strict";
-
-module.exports = {
-    register_key: "objectToHtmlRender",
-
-    /**
-     * Register "this" as a window scope accessible variable named by the given key, or default.
-     * @param {String} key 
-     */
-    register(key) {
-        const register_key = key || this.register_key;
-        window[register_key] = this;
-    },
-
-    /**
-     * This must be called before any other method in order to initialize the lib.
-     * It provides the root of the rendering cycle as a Javascript object.
-     * @param {Object} renderCycleRoot A JS component with a render method.
-     */
-    setRenderCycleRoot(renderCycleRoot) {
-        this.renderCycleRoot = renderCycleRoot;
-    },
-
-    event_name: "objtohtml-render-cycle",
-
-    /**
-     * Set a custom event name for the event that is trigger on render cycle.
-     * Default is "objtohtml-render-cycle".
-     * @param {String} evt_name 
-     */
-    setEventName(evt_name) {
-        this.event_name = evt_name;
-    },
-
-    /**
-     * This is the core agorithm that read an javascript Object and convert it into an HTML element.
-     * @param {Object} obj The object representing the html element must be formatted like:
-     * {
-     *      tag: String // The name of the html tag, Any valid html tag should work. div, section, br, ul, li...
-     *      xmlns: String // This can replace the tag key if the element is an element with a namespace URI, for example an <svg> tag.
-     *                      See https://developer.mozilla.org/en-US/docs/Web/API/Document/createElementNS for more information
-     *      style_rules: Object // a object providing css attributes. The attributes names must be in JS syntax,
-     *                              like maxHeight: "500px", backgrouncColor: "#ff2d56",  margin: 0,  etc.
-     *      contents: Array or String // This reprensents the contents that will be nested in the created html element.
-     *                                   <div>{contents}</div>
-     *                                   The contents can be an array of other objects reprenting elements (with tag, contents, etc)
-     *                                   or it can be a simple string.
-     *      // All other attributes will be parsed as html attributes. They can be anything like onclick, href, onchange, title...
-     *      // or they can also define custom html5 attributes, like data, my_custom_attr or anything.
-     * }
-     * @returns {HTMLElement} The output html node.
-     */
-    objectToHtml(obj) {
-        if (!obj) return document.createElement("span"); // in case of invalid input, don't block the whole process.
-        const objectToHtml = this.objectToHtml.bind(this);
-        const { tag, xmlns } = obj;
-        const node = xmlns !== undefined ? document.createElementNS(xmlns, tag) : document.createElement(tag);
-        const excludeKeys = ["tag", "contents", "style_rules", "state", "xmlns"];
-
-        Object.keys(obj)
-            .filter(attr => !excludeKeys.includes(attr))
-            .forEach(attr => {
-                switch (attr) {
-                    case "class":
-                        node.classList.add(...obj[attr].split(" ").filter(s => s !== ""));
-                        break;
-                    case "on_render":
-                        if (!obj.id) {
-                            node.id = `${btoa(JSON.stringify(obj).slice(0, 127)).replace(/\=/g, '')}${window.performance.now()}`;
-                        }
-                        if (typeof obj.on_render !== "function") {
-                            console.error("The on_render attribute must be a function")
-                        } else {
-                            this.attach_on_render_callback(node, obj.on_render);
-                        }
-                        break;
-                    default:
-                        if (xmlns !== undefined) {
-                            node.setAttributeNS(null, attr, obj[attr])
-                        } else {
-                            node[attr] = obj[attr];
-                        }
-                }
-            });
-        if (obj.contents && typeof obj.contents === "string") {
-            node.innerHTML = obj.contents;
-        } else {
-            obj.contents &&
-                obj.contents.length > 0 &&
-                obj.contents.forEach(el => {
-                    switch (typeof el) {
-                        case "string":
-                            node.innerHTML = el;
-                            break;
-                        case "object":
-                            if (xmlns !== undefined) {
-                                el = Object.assign(el, { xmlns })
-                            }
-                            node.appendChild(objectToHtml(el));
-                            break;
-                    }
-                });
-        }
-
-        if (obj.style_rules) {
-            Object.keys(obj.style_rules).forEach(rule => {
-                node.style[rule] = obj.style_rules[rule];
-            });
-        }
-
-        return node;
-    },
-
-    on_render_callbacks: [],
-
-    /**
-     * This is called if the on_render attribute of a component is set.
-     * @param {HTMLElement} node The created html element
-     * @param {Function} callback The callback defined in the js component to render
-     */
-    attach_on_render_callback(node, callback) {
-        const callback_handler = {
-            callback: e => {
-                if (e.detail.outputNode === node || e.detail.outputNode.querySelector(`#${node.id}`)) {
-                    callback(node);
-                    const handler_index = this.on_render_callbacks.indexOf((this.on_render_callbacks.find(cb => cb.node === node)));
-                    if (handler_index === -1) {
-                        console.warn("A callback was registered for node with id " + node.id + " but callbacck handler is undefined.")
-                    } else {
-                        window.removeEventListener(this.event_name, this.on_render_callbacks[handler_index].callback)
-                        this.on_render_callbacks.splice(handler_index, 1);
-                    }
-                }
-            },
-            node,
-        };
-
-        const len = this.on_render_callbacks.push(callback_handler);
-        window.addEventListener(this.event_name, this.on_render_callbacks[len - 1].callback);
-    },
-
-    /**
-     * If a main element exists in the html document, it will be used as rendering root.
-     * If not, it will be created and inserted.
-     */
-    renderCycle: function () {
-        const main_elmt = document.getElementsByTagName("main")[0] || (function () {
-            const created_main = document.createElement("main");
-            document.body.appendChild(created_main);
-            return created_main;
-        })();
-
-        this.subRender(this.renderCycleRoot.render(), main_elmt, { mode: "replace" });
-    },
-
-    /**
-     * This method behaves like the renderCycle() method, but rather that starting the rendering cycle from the root component,
-    * it can start from any component of the tree. The root component must be given as the first argument, the second argument be
-    * be a valid html element in the dom and will be used as the insertion target.
-     * @param {Object} object An object providing a render method returning an object representation of the html to insert
-     * @param {HTMLElement} htmlNode The htlm element to update
-     * @param {Object} options can be used the define the insertion mode, default is set to "append" and can be set to "override",
-         * "insert-before" (must be defined along with an insertIndex key (integer)),
-         * "adjacent" (must be defined along with an insertLocation key (String)), "replace" or "remove".
-         * In case of "remove", the first argument "object" is not used and can be set to null, undefined or {}...
-     */
-    subRender(object, htmlNode, options = { mode: "append" }) {
-        let outputNode = null;
-
-        const get_insert = () => {
-            outputNode = this.objectToHtml(object);
-            return outputNode;
-        };
-
-        switch (options.mode) {
-            case "append":
-                htmlNode.appendChild(get_insert());
-                break;
-            case "override":
-                htmlNode.innerHTML = "";
-                htmlNode.appendChild(get_insert());
-                break;
-            case "insert-before":
-                htmlNode.insertBefore(get_insert(), htmlNode.childNodes[options.insertIndex]);
-                break;
-            case "adjacent":
-                /**
-                 * options.insertLocation must be one of:
-                 *
-                 * afterbegin
-                 * afterend
-                 * beforebegin
-                 * beforeend
-                 */
-                htmlNode.insertAdjacentHTML(options.insertLocation, get_insert());
-                break;
-            case "replace":
-                htmlNode.parentNode.replaceChild(get_insert(), htmlNode);
-                break;
-            case "remove":
-                htmlNode.remove();
-                break;
-        }
-        const evt_name = this.event_name;
-        const event = new CustomEvent(evt_name, {
-            detail: {
-                inputObject: object,
-                outputNode,
-                insertOptions: options,
-                targetNode: htmlNode,
-            }
-        });
-
-        window.dispatchEvent(event);
-    },
-};
-},{}],6:[function(require,module,exports){
-"use strict";
-
-const { fetch_json_or_error_text } = require("./fetch");
-
-function getArticleBody(text) {
-    return text.replaceAll("\n", "<br/>");
-}
-
-function getArticleDate(date) {
-    return `${date.getDate()}-${date.getMonth() + 1}-${date.getFullYear()}`;
-}
-
-function loadArticles(category, locale) {
-    return fetch_json_or_error_text(`/articles/${category}/${locale}`);
-}
-
-module.exports = {
-    loadArticles,
-    getArticleBody,
-    getArticleDate,
-};
-
-},{"./fetch":7}],7:[function(require,module,exports){
-"use strict";
-
-function fetchjson(url) {
-    return new Promise((resolve, reject) => {
-        fetch(url)
-            .then(r => r.json())
-            .then(r => resolve(r))
-            .catch(e => reject(e));
-    });
-}
-
-function fetchtext(url) {
-    return new Promise((resolve, reject) => {
-        fetch(url)
-            .then(r => r.text())
-            .then(r => resolve(r))
-            .catch(e => reject(e));
-    });
-}
-
-async function fetch_json_or_error_text(url, options = {}) {
-    return new Promise((resolve, reject) => {
-        fetch(url, options).then(async res => {
-            if (res.status >= 400 && res.status < 600) {
-                reject(await res.text());
-            } else {
-                resolve(await res.json());
-            }
-        })
-    })
-}
-
-module.exports = {
-    fetchjson,
-    fetchtext,
-    fetch_json_or_error_text,
-};
-
-},{}],8:[function(require,module,exports){
-"use strict";
-const translator = require("ks-cheap-translator");
-const { translations_url } = require("../../constants");
-
-class WebPage {
-    constructor(args) {
-        Object.assign(this, args);
-
-        if (!this.id) {
-            this.id = "webpage-" + performance.now();
-        }
-
-        translator.init({
-            translations_url,
-            supported_languages: ["fr", "en"],
-        }).then(this.refresh_all.bind(this));
-    }
-
-    refresh() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), { mode: "replace" })
-    }
-
-    refresh_all() {
-        obj2htm.renderCycle()
-    }
-}
-
-module.exports = WebPage;
-},{"../../constants":3,"ks-cheap-translator":4}],9:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../../../admin-frontend/src/constants");
-const { getArticleBody } = require("../../../lib/article-utils");
-
-class SoftwareArticle {
-    constructor(props) {
-        this.props = props;
-    }
-
-    render() {
-        const { title, body, subtitle, images, details = [] } = this.props;
-
-        return {
-            tag: "article",
-            class: "software-article",
-            typeof: "SoftwareApplication",
-            additionalType: "Article",
-            contents: [
-                {
-                    tag: "h2",
-                    class: "software-title",
-                    contents: title,
-                    property: "name",
-                },
-                {
-                    tag: "div", class: "software-image",
-                    contents: [
-                        {
-                            tag: "img", src: `${images_url}/${images[0]}`
-                        }
-                    ]
-                },
-                {
-                    tag: "h3",
-                    class: "software-subtitle",
-                    contents: subtitle,
-                    property: "alternativeHeadline",
-                },
-                {
-                    tag: "div",
-                    class: "software-description",
-                    contents: getArticleBody(body),
-                    property: "description",
-                },
-                details.length > 0 && {
-                    tag: "div",
-                    class: "article-details",
-                    contents: [
-                        {
-                            tag: "h2",
-                            contents: "Details",
-                        },
-                        {
-                            tag: "ul",
-                            class: "details-list",
-                            contents: details.map(detail => {
-                                return {
-                                    tag: "li",
-                                    class: "detail",
-                                    contents: [
-                                        { tag: "label", contents: detail.label },
-                                        {
-                                            tag: "div",
-                                            class: "detail-value",
-                                            contents: detail.value
-                                        },
-                                    ],
-                                };
-                            }),
-                        },
-                    ],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = SoftwareArticle;
-},{"../../../../../admin-frontend/src/constants":1,"../../../lib/article-utils":6}],10:[function(require,module,exports){
-"use strict";
-
-const { loadArticles } = require("../../../lib/article-utils");
-const SoftwareArticle = require("./software-article");
-const translator = require("ks-cheap-translator");
-
-class SoftwareArticles {
-    constructor(props) {
-        this.props = props;
-        this.state = {
-            articles: [],
-        };
-        this.id = "software-articles-section";
-        this.loadArticles();
-    }
-
-    loadArticles() {
-        loadArticles("software", translator.locale).then(articles => {
-            this.state.articles = articles;
-            this.refresh();
-            this.fixScroll();
-        }).catch(e => console.log(e))
-    }
-
-    renderPlaceholder() {
-        return {
-            tag: "article",
-            class: "placeholder",
-            contents: [
-                { tag: "div", class: "title" },
-                { tag: "div", class: "body" },
-                { tag: "div", class: "details" },
-            ],
-        };
-    }
-
-    refresh() {
-        obj2htm.subRender(this.render(), document.getElementById(this.id), {
-            mode: "replace",
-        });
-    }
-
-    fixScroll() {
-        if (window.location.href.includes("#")) {
-            window.scrollTo(
-                0,
-                document.getElementById(window.location.href.match(/#.+/)[0].replace("#", ""))
-                    .offsetTop
-            );
-        }
-    }
-
-    render() {
-        const { articles } = this.state;
-        return {
-            tag: "section",
-            class: "software-articles page-contents-center",
-            id: this.id,
-            contents:
-                articles.length > 0
-                    ? articles.map(article => new SoftwareArticle({ ...article }).render())
-                    : [this.renderPlaceholder()],
-        };
-    }
-}
-
-module.exports = SoftwareArticles;
-
-},{"../../../lib/article-utils":6,"./software-article":9,"ks-cheap-translator":4}],11:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const WebPage = require("../../lib/web-page");
-const SoftwareArticles = require("./components/software-articles");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-class SoftwareDevelopment extends WebPage {
-    render() {
-        return {
-            tag: "div",
-            id: "software-page",
-            contents: [
-                {
-                    tag: "div",
-                    class: "page-header logo-left",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "page-contents-center grid-wrapper",
-                            contents: [
-                                {
-                                    tag: "div",
-                                    class: "logo",
-                                    contents: [
-                                        {
-                                            tag: "img",
-                                            alt: `image mechanic electronic`,
-                                            src: `${images_url}/meca_proc.svg`,
-                                        },
-                                    ],
-                                },
-                                { tag: "h1", contents: "Software" },
-                                {
-                                    tag: "p",
-                                    contents: t("software-page-intro"),
-                                },
-                            ],
-                        },
-                    ],
-                },
-                new SoftwareArticles().render(),
-            ],
-        };
-    }
-}
-
-module.exports = SoftwareDevelopment;
-
-},{"../../../constants":3,"../../lib/web-page":8,"./components/software-articles":10,"ks-cheap-translator":4}],12:[function(require,module,exports){
-"use strict";
-
-"use strict";
-const runPage = require("../../run-page");
-const SoftwareDevelopment = require("./software-development");
-runPage(SoftwareDevelopment);
-
-},{"../../run-page":13,"./software-development":11}],13:[function(require,module,exports){
-"use strict";
-
-const renderer = require("object-to-html-renderer")
-const Template = require("./template/template");
-
-module.exports = function runPage(PageComponent) {
-    const template = new Template({ page: new PageComponent() });
-    renderer.register("obj2htm")
-    obj2htm.setRenderCycleRoot(template);
-    obj2htm.renderCycle();
-};
-
-},{"./template/template":15,"object-to-html-renderer":5}],14:[function(require,module,exports){
-"use strict";
-
-const { images_url } = require("../../../constants");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator);
-
-const NAV_MENU_ITEMS = [
-    { url: "/games/", text: "Jeux" },
-    {
-        url: "/education/",
-        text: "Pédagogie",
-    },
-    { url: "/software-development/", text: "Software" }
-];
-
-class NavBar {
-    constructor() {
-        this.initEventHandlers();
-    }
-
-    handleBurgerClick() {
-        document.getElementById("nav-menu-list").classList.toggle("responsive-show");
-    }
-
-    initEventHandlers() {
-        window.addEventListener("click", event => {
-            if (
-                event.target.id !== "nav-menu-list" &&
-                !event.target.classList.contains("burger") &&
-                !event.target.parentNode.classList.contains("burger")
-            ) {
-                document.getElementById("nav-menu-list").classList.remove("responsive-show");
-            }
-        });
-    }
-
-    handle_chang_lang(lang) {
-        translator.update_translations(lang).then(() => {
-            obj2htm.renderCycle();
-        }).catch(err => console.log(err));
-    }
-
-    renderHome() {
-        return {
-            tag: "div",
-            class: "home",
-            contents: [
-                {
-                    tag: "a",
-                    href: "/",
-                    contents: [
-                        {
-                            tag: "img",
-                            alt: "Logo Kuadrado",
-                            src: `${images_url}/logo_kuadrado.svg`,
-                        },
-                        {
-                            tag: "img",
-                            alt: "Kuadrado Software",
-                            class: "logo-text",
-                            src: `${images_url}/logo_kuadrado_txt.svg`,
-                        },
-                    ],
-                },
-            ],
-        };
-    }
-
-    renderMenu(menuItemsArray, isSubmenu = false, parentUrl = "") {
-        return {
-            tag: "ul",
-            id: "nav-menu-list",
-            class: isSubmenu ? "submenu" : "",
-            contents: menuItemsArray.map(item => {
-                const { url, text, submenu } = item;
-                const href = `${parentUrl}${url}`;
-                return {
-                    tag: "li",
-                    class: !isSubmenu && window.location.pathname === href ? "active" : "",
-                    contents: [
-                        {
-                            tag: "a",
-                            href,
-                            contents: t(text),
-                        },
-                    ].concat(submenu ? [this.renderMenu(submenu, true, url)] : []),
-                };
-            }).concat({
-                tag: "li",
-                class: "lang-flags",
-                contents: ["fr", "en"].map(lang => {
-                    return {
-                        tag: "img", src: `${images_url}/flag-${lang}.svg`,
-                        class: translator.locale === lang ? "selected" : "",
-                        onclick: this.handle_chang_lang.bind(this, lang)
-                    }
-                })
-            }),
-        };
-    }
-
-    renderResponsiveBurger() {
-        return {
-            tag: "div",
-            class: "burger",
-            onclick: this.handleBurgerClick.bind(this),
-            contents: [{ tag: "span", contents: "···" }],
-        };
-    }
-
-    render() {
-        return {
-            tag: "nav",
-            contents: [
-                this.renderHome(),
-                this.renderResponsiveBurger(),
-                this.renderMenu(NAV_MENU_ITEMS),
-            ],
-        };
-    }
-}
-
-module.exports = NavBar;
-
-},{"../../../constants":3,"ks-cheap-translator":4}],15:[function(require,module,exports){
-"use strict";
-
-const { in_construction } = require("../../config");
-const { images_url } = require("../../constants");
-const NavBar = require("./components/navbar");
-const translator = require("ks-cheap-translator");
-const t = translator.trad.bind(translator)
-
-class Template {
-    constructor(props) {
-        this.props = props;
-    }
-    render() {
-        return {
-            tag: "main",
-            contents: [
-                {
-                    tag: "header",
-                    contents: [new NavBar().render()],
-                },
-                in_construction && {
-                    tag: "section",
-                    class: "warning-banner",
-                    contents: [
-                        {
-                            tag: "strong",
-                            class: "page-contents-center",
-                            contents: t("Site en construction ..."),
-                        },
-                    ],
-                },
-                {
-                    tag: "section",
-                    id: "page-container",
-                    contents: [this.props.page.render()],
-                },
-                {
-                    tag: "footer",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "logo",
-                            contents: [
-                                {
-                                    tag: "img",
-                                    alt: `logo Kuadrado`,
-                                    src: `${images_url}/logo_kuadrado.svg`,
-                                },
-                                {
-                                    tag: "img",
-                                    class: "text-logo",
-                                    alt: "Kuadrado Software",
-                                    src: `${images_url}/logo_kuadrado_txt.svg`,
-                                },
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: "32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France",
-                        },
-                        {
-                            tag: "div",
-                            contents: [
-                                { tag: "strong", contents: "<blue>Contact : </blue>" },
-                                {
-                                    tag: "a",
-                                    href: "mailto:contact@kuadrado-software.fr",
-                                    contents: "contact@kuadrado-software.fr",
-                                },
-                            ],
-                        },
-                        {
-                            tag: "div",
-                            class: "social",
-                            contents: [
-                                {
-                                    tag: "strong",
-                                    contents: `<blue>${t("Sur les réseaux")} : </blue>`,
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://www.linkedin.com/company/kuadrado-software",
-                                    target: "_blank",
-                                    contents: "in",
-                                    title: "Linkedin",
-                                },
-                                {
-                                    tag: "a",
-                                    href: "https://mastodon.gamedev.place/@kuadrado_software",
-                                    target: "_blank",
-                                    contents: "m",
-                                    title: "Mastodon",
-                                }
-                            ],
-                        },
-                        {
-                            tag: "span",
-                            contents: `Copyleft 🄯 ${new Date()
-                                .getFullYear()} Kuadrado Software | 
-                                ${t("kuadrado-footer-copyleft")}`,
-                        },
-                        {
-                            tag: "div", contents: [
-                                { tag: "span", contents: t("Ce site web est") + " " },
-                                {
-                                    tag: "a", target: "_blank",
-                                    style_rules: { fontWeight: "bold" },
-                                    href: "https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",
-                                    contents: "OPEN SOURCE"
-                                }
-                            ]
-                        }
-                    ],
-                },
-            ],
-        };
-    }
-}
-
-module.exports = Template;
-
-},{"../../config":2,"../../constants":3,"./components/navbar":14,"ks-cheap-translator":4}]},{},[12]);
+!function s(a,r,o){function c(e,t){if(!r[e]){if(!a[e]){var n="function"==typeof require&&require;if(!t&&n)return n(e,!0);if(i)return i(e,!0);throw(n=new Error("Cannot find module '"+e+"'")).code="MODULE_NOT_FOUND",n}n=r[e]={exports:{}},a[e][0].call(n.exports,function(t){return c(a[e][1][t]||t)},n,n.exports,s,a,r,o)}return r[e].exports}for(var i="function"==typeof require&&require,t=0;t<o.length;t++)c(o[t]);return c}({1:[function(t,e,n){e.exports={images_url:"/assets/images"}},{}],2:[function(t,e,n){e.exports={build:{protected_dirs:["assets","style","views","standard"],default_meta_keys:["title","description","image","open_graph","json_ld"]}}},{}],3:[function(t,e,n){e.exports={images_url:"/assets/images",data_url:"/assets/data",translations_url:"/assets/translations"}},{}],4:[function(t,e,n){"use strict";e.exports={locale:"en",supported_languages:["en"],translations:{},translations_url:"",use_url_locale_fragment:!0,local_storage_key:"translator-prefered-language",init(t){return Object.entries(t).forEach(t=>{var[e,t]=t;["supported_languages","use_url_locale_fragment","local_storage_key","translations_url"].includes(e)&&(this[e]=t)}),this.translations_url=this.format_translations_url(this.translations_url),this.supported_languages=this.format_supported_languages(this.supported_languages),new Promise((e,n)=>{const s=(()=>{if(this.use_url_locale_fragment){var t=window.location.pathname.substring(1).split("/")[0];return this.supported_languages.includes(t)?t:""}return""})()||localStorage.getItem(this.local_storage_key)||(t=navigator.language.split("-")[0].toLocaleLowerCase(),this.supported_languages.includes(t)?t:this.supported_languages[0]);var t;fetch(`${this.translations_url}${s}.json`).then(t=>t.json()).then(t=>{this.locale=s,this.translations=t,e()}).catch(t=>{this.locale="en",n(t)})})},format_locale(t){return t.split("-")[0].toLowerCase()},format_translations_url(t){return"/"!==t.charAt(t.length-1)&&(t+="/"),t},format_supported_languages(t){return t.map(t=>this.format_locale(t))},update_translations(s){return s=this.format_locale(s),new Promise((n,e)=>{fetch(`${this.translations_url}${s}.json`).then(t=>t.json()).then(t=>{this.translations=t,this.locale=s,localStorage.setItem(this.local_storage_key,s);const e=window.location.pathname.substring(1).split("/");t=e[0];this.supported_languages.includes(t)&&(e.splice(0,1,s),t=e.join("/"),window.history.replaceState(null,"","/"+t)),n()}).catch(t=>{e(t)})})},trad:function(e,n={}){return e=this.translations[e]||e,Object.keys(n).forEach(t=>{e=e.replace(`{%${t}%}`,n[t])}),e}}},{}],5:[function(t,e,n){"use strict";e.exports={register_key:"objectToHtmlRender",register(t){t=t||this.register_key;window[t]=this},setRenderCycleRoot(t){this.renderCycleRoot=t},event_name:"objtohtml-render-cycle",setEventName(t){this.event_name=t},objectToHtml(e){if(!e)return document.createElement("span");const n=this.objectToHtml.bind(this),{tag:t,xmlns:s}=e,a=void 0!==s?document.createElementNS(s,t):document.createElement(t),r=["tag","contents","style_rules","state","xmlns"];return Object.keys(e).filter(t=>!r.includes(t)).forEach(t=>{switch(t){case"class":a.classList.add(...e[t].split(" ").filter(t=>""!==t));break;case"on_render":e.id||(a.id=`${btoa(JSON.stringify(e).slice(0,127)).replace(/\=/g,"")}${window.performance.now()}`),"function"!=typeof e.on_render?console.error("The on_render attribute must be a function"):this.attach_on_render_callback(a,e.on_render);break;default:void 0!==s?a.setAttributeNS(null,t,e[t]):a[t]=e[t]}}),e.contents&&"string"==typeof e.contents?a.innerHTML=e.contents:e.contents&&0<e.contents.length&&e.contents.forEach(t=>{switch(typeof t){case"string":a.innerHTML=t;break;case"object":void 0!==s&&(t=Object.assign(t,{xmlns:s})),a.appendChild(n(t))}}),e.style_rules&&Object.keys(e.style_rules).forEach(t=>{a.style[t]=e.style_rules[t]}),a},on_render_callbacks:[],attach_on_render_callback(e,n){var t={callback:t=>{t.detail.outputNode!==e&&!t.detail.outputNode.querySelector(`#${e.id}`)||(n(e),-1===(t=this.on_render_callbacks.indexOf(this.on_render_callbacks.find(t=>t.node===e)))?console.warn("A callback was registered for node with id "+e.id+" but callbacck handler is undefined."):(window.removeEventListener(this.event_name,this.on_render_callbacks[t].callback),this.on_render_callbacks.splice(t,1)))},node:e},t=this.on_render_callbacks.push(t);window.addEventListener(this.event_name,this.on_render_callbacks[t-1].callback)},renderCycle:function(){var t,t=document.getElementsByTagName("main")[0]||(t=document.createElement("main"),document.body.appendChild(t),t);this.subRender(this.renderCycleRoot.render(),t,{mode:"replace"})},subRender(t,e,n={mode:"append"}){let s=null;var a=()=>(s=this.objectToHtml(t),s);switch(n.mode){case"append":e.appendChild(a());break;case"override":e.innerHTML="",e.appendChild(a());break;case"insert-before":e.insertBefore(a(),e.childNodes[n.insertIndex]);break;case"adjacent":e.insertAdjacentHTML(n.insertLocation,a());break;case"replace":e.parentNode.replaceChild(a(),e);break;case"remove":e.remove()}var r=this.event_name,r=new CustomEvent(r,{detail:{inputObject:t,outputNode:s,insertOptions:n,targetNode:e}});window.dispatchEvent(r)}}},{}],6:[function(t,e,n){"use strict";const{fetch_json_or_error_text:s}=t("./fetch");e.exports={loadArticles:function(t,e){return s(`/articles/${t}/${e}`)},getArticleBody:function(t){return t.replaceAll("\n","<br/>")},getArticleDate:function(t){return`${t.getDate()}-${t.getMonth()+1}-${t.getFullYear()}`}}},{"./fetch":7}],7:[function(t,e,n){"use strict";e.exports={fetchjson:function(t){return new Promise((e,n)=>{fetch(t).then(t=>t.json()).then(t=>e(t)).catch(t=>n(t))})},fetchtext:function(t){return new Promise((e,n)=>{fetch(t).then(t=>t.text()).then(t=>e(t)).catch(t=>n(t))})},fetch_json_or_error_text:async function(t,s={}){return new Promise((e,n)=>{fetch(t,s).then(async t=>{400<=t.status&&t.status<600?n(await t.text()):e(await t.json())})})}}},{}],8:[function(t,e,n){"use strict";const s=t("ks-cheap-translator"),{translations_url:a}=t("../../constants");e.exports=class{constructor(t){Object.assign(this,t),this.id||(this.id="webpage-"+performance.now()),s.init({translations_url:a,supported_languages:["fr","en"]}).then(this.refresh_all.bind(this))}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}refresh_all(){obj2htm.renderCycle()}}},{"../../constants":3,"ks-cheap-translator":4}],9:[function(t,e,n){"use strict";const{images_url:r}=t("../../../../../admin-frontend/src/constants"),{getArticleBody:o}=t("../../../lib/article-utils");e.exports=class{constructor(t){this.props=t}render(){const{title:t,body:e,subtitle:n,images:s,details:a=[]}=this.props;return{tag:"article",class:"software-article",typeof:"SoftwareApplication",additionalType:"Article",contents:[{tag:"h2",class:"software-title",contents:t,property:"name"},{tag:"div",class:"software-image",contents:[{tag:"img",src:`${r}/${s[0]}`}]},{tag:"h3",class:"software-subtitle",contents:n,property:"alternativeHeadline"},{tag:"div",class:"software-description",contents:o(e),property:"description"},0<a.length&&{tag:"div",class:"article-details",contents:[{tag:"h2",contents:"Details"},{tag:"ul",class:"details-list",contents:a.map(t=>({tag:"li",class:"detail",contents:[{tag:"label",contents:t.label},{tag:"div",class:"detail-value",contents:t.value}]}))}]}]}}}},{"../../../../../admin-frontend/src/constants":1,"../../../lib/article-utils":6}],10:[function(t,e,n){"use strict";const{loadArticles:s}=t("../../../lib/article-utils"),a=t("./software-article"),r=t("ks-cheap-translator");e.exports=class{constructor(t){this.props=t,this.state={articles:[]},this.id="software-articles-section",this.loadArticles()}loadArticles(){s("software",r.locale).then(t=>{this.state.articles=t,this.refresh(),this.fixScroll()}).catch(t=>console.log(t))}renderPlaceholder(){return{tag:"article",class:"placeholder",contents:[{tag:"div",class:"title"},{tag:"div",class:"body"},{tag:"div",class:"details"}]}}refresh(){obj2htm.subRender(this.render(),document.getElementById(this.id),{mode:"replace"})}fixScroll(){window.location.href.includes("#")&&window.scrollTo(0,document.getElementById(window.location.href.match(/#.+/)[0].replace("#","")).offsetTop)}render(){const{articles:t}=this.state;return{tag:"section",class:"software-articles page-contents-center",id:this.id,contents:0<t.length?t.map(t=>new a({...t}).render()):[this.renderPlaceholder()]}}}},{"../../../lib/article-utils":6,"./software-article":9,"ks-cheap-translator":4}],11:[function(t,e,n){"use strict";const{images_url:s}=t("../../../constants");var a=t("../../lib/web-page");const r=t("./components/software-articles"),o=t("ks-cheap-translator"),c=o.trad.bind(o);class i extends a{render(){return{tag:"div",id:"software-page",contents:[{tag:"div",class:"page-header logo-left",contents:[{tag:"div",class:"page-contents-center grid-wrapper",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"image mechanic electronic",src:`${s}/meca_proc.svg`}]},{tag:"h1",contents:"Software"},{tag:"p",contents:c("software-page-intro")}]}]},(new r).render()]}}}e.exports=i},{"../../../constants":3,"../../lib/web-page":8,"./components/software-articles":10,"ks-cheap-translator":4}],12:[function(t,e,n){"use strict";const s=t("../../run-page");t=t("./software-development");s(t)},{"../../run-page":13,"./software-development":11}],13:[function(t,e,n){"use strict";const s=t("object-to-html-renderer"),a=t("./template/template");e.exports=function(t){t=new a({page:new t});s.register("obj2htm"),obj2htm.setRenderCycleRoot(t),obj2htm.renderCycle()}},{"./template/template":15,"object-to-html-renderer":5}],14:[function(t,e,n){"use strict";const{images_url:s}=t("../../../constants"),o=t("ks-cheap-translator"),c=o.trad.bind(o),a=[{url:"/games/",text:"Jeux"},{url:"/education/",text:"Pédagogie"},{url:"/software-development/",text:"Software"}];e.exports=class{constructor(){this.initEventHandlers()}handleBurgerClick(){document.getElementById("nav-menu-list").classList.toggle("responsive-show")}initEventHandlers(){window.addEventListener("click",t=>{"nav-menu-list"===t.target.id||t.target.classList.contains("burger")||t.target.parentNode.classList.contains("burger")||document.getElementById("nav-menu-list").classList.remove("responsive-show")})}handle_chang_lang(t){o.update_translations(t).then(()=>{obj2htm.renderCycle()}).catch(t=>console.log(t))}renderHome(){return{tag:"div",class:"home",contents:[{tag:"a",href:"/",contents:[{tag:"img",alt:"Logo Kuadrado",src:`${s}/logo_kuadrado.svg`},{tag:"img",alt:"Kuadrado Software",class:"logo-text",src:`${s}/logo_kuadrado_txt.svg`}]}]}}renderMenu(t,a=!1,r=""){return{tag:"ul",id:"nav-menu-list",class:a?"submenu":"",contents:t.map(t=>{var{url:e,text:n,submenu:t}=t;const s=`${r}${e}`;return{tag:"li",class:a||window.location.pathname!==s?"":"active",contents:[{tag:"a",href:s,contents:c(n)}].concat(t?[this.renderMenu(t,!0,e)]:[])}}).concat({tag:"li",class:"lang-flags",contents:["fr","en"].map(t=>({tag:"img",src:`${s}/flag-${t}.svg`,class:o.locale===t?"selected":"",onclick:this.handle_chang_lang.bind(this,t)}))})}}renderResponsiveBurger(){return{tag:"div",class:"burger",onclick:this.handleBurgerClick.bind(this),contents:[{tag:"span",contents:"···"}]}}render(){return{tag:"nav",contents:[this.renderHome(),this.renderResponsiveBurger(),this.renderMenu(a)]}}}},{"../../../constants":3,"ks-cheap-translator":4}],15:[function(t,e,n){"use strict";const{in_construction:s}=t("../../config"),{images_url:a}=t("../../constants"),r=t("./components/navbar"),o=t("ks-cheap-translator"),c=o.trad.bind(o);e.exports=class{constructor(t){this.props=t}render(){return{tag:"main",contents:[{tag:"header",contents:[(new r).render()]},s&&{tag:"section",class:"warning-banner",contents:[{tag:"strong",class:"page-contents-center",contents:c("Site en construction ...")}]},{tag:"section",id:"page-container",contents:[this.props.page.render()]},{tag:"footer",contents:[{tag:"div",class:"logo",contents:[{tag:"img",alt:"logo Kuadrado",src:`${a}/logo_kuadrado.svg`},{tag:"img",class:"text-logo",alt:"Kuadrado Software",src:`${a}/logo_kuadrado_txt.svg`}]},{tag:"span",contents:"32 rue Simon Vialet, 07240 Vernoux en Vivarais. Ardèche, France"},{tag:"div",contents:[{tag:"strong",contents:"<blue>Contact : </blue>"},{tag:"a",href:"mailto:contact@kuadrado-software.fr",contents:"contact@kuadrado-software.fr"}]},{tag:"div",class:"social",contents:[{tag:"strong",contents:`<blue>${c("Sur les réseaux")} : </blue>`},{tag:"a",href:"https://www.linkedin.com/company/kuadrado-software",target:"_blank",contents:"in",title:"Linkedin"},{tag:"a",href:"https://mastodon.gamedev.place/@kuadrado_software",target:"_blank",contents:"m",title:"Mastodon"}]},{tag:"span",contents:`Copyleft 🄯 ${(new Date).getFullYear()} Kuadrado Software | 
+                                ${c("kuadrado-footer-copyleft")}`},{tag:"div",contents:[{tag:"span",contents:c("Ce site web est")+" "},{tag:"a",target:"_blank",style_rules:{fontWeight:"bold"},href:"https://gitlab.com/kuadrado-software/kuadrado-website/-/blob/master/README.md",contents:"OPEN SOURCE"}]}]}]}}}},{"../../config":2,"../../constants":3,"./components/navbar":14,"ks-cheap-translator":4}]},{},[12]);
\ No newline at end of file
diff --git a/public/style/style.css b/public/style/style.css
deleted file mode 100644
index db7b101962d4bf8d3e1fd5c1228b671f0fea983d..0000000000000000000000000000000000000000
--- a/public/style/style.css
+++ /dev/null
@@ -1,987 +0,0 @@
-body {
-  font-family: Arial, Helvetica, sans-serif;
-  margin: 0;
-}
-body * {
-  box-sizing: border-box;
-  color: #35393c;
-  line-height: 1.3em;
-}
-body ul {
-  margin: 0;
-  padding: 0;
-  list-style-type: none;
-}
-body a {
-  color: #4baabb;
-  text-decoration: none;
-}
-body a:hover {
-  color: #72e3f0;
-}
-body blue {
-  color: #4baabb;
-}
-body red {
-  color: #9c3030;
-}
-body green {
-  color: #368736;
-}
-body emoji {
-  font-style: initial;
-  font-size: 25px;
-}
-body .bg-blue {
-  background-color: #4baabb;
-  color: white;
-}
-body .bg-dark {
-  background-color: #3c4144;
-  color: #aabbc8;
-}
-body #seo-title {
-  visibility: hidden;
-}
-body img.pixelated {
-  image-rendering: pixelated;
-  image-rendering: -moz-crisp-edges;
-  image-rendering: crisp-edges;
-}
-
-main {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  min-height: 100vh;
-}
-main .warning-banner {
-  background: url("/assets/images/wallpaper_warning.svg");
-  width: 100%;
-  height: 40px;
-  padding: 20px 10%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-}
-main .warning-banner strong {
-  font-size: 18px;
-  color: #1c3db2;
-}
-main .image-carousel {
-  overflow: hidden;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  background-color: black;
-  position: relative;
-}
-main .image-carousel img {
-  position: absolute;
-  object-fit: contain;
-  height: 80%;
-  max-width: 100%;
-}
-main .image-carousel .carousel-bullets {
-  position: absolute;
-  bottom: 0;
-  padding: 20px;
-  display: flex;
-  gap: 10px;
-}
-main .image-carousel .carousel-bullets .bullet {
-  cursor: pointer;
-  width: 8px;
-  height: 8px;
-  background-color: #6b7880;
-  border-radius: 100%;
-  box-shadow: 0 0 3px black;
-}
-main .image-carousel .carousel-bullets .bullet.active {
-  background-color: #d4d9dd;
-}
-@media screen and (max-width: 900px) {
-  main .image-carousel .carousel-bullets {
-    gap: 30px;
-  }
-  main .image-carousel .carousel-bullets .bullet {
-    width: 12px;
-    height: 12px;
-  }
-}
-main header {
-  width: 100%;
-  background-color: white;
-  position: sticky;
-  position: -webkit-sticky;
-  top: 0;
-  z-index: 10;
-}
-main header nav {
-  display: flex;
-  align-items: center;
-  height: 60px;
-}
-main header nav .home {
-  margin: 0 10px;
-}
-main header nav .home a {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-}
-main header nav .home a img {
-  height: 40px;
-  width: auto;
-}
-main header nav .home a img.logo-text {
-  width: 120px;
-  height: auto;
-}
-main header nav ul {
-  display: flex;
-  padding: 0;
-  margin: 0;
-  list-style-type: none;
-  height: 100%;
-  flex: 1;
-}
-main header nav ul li {
-  position: relative;
-}
-main header nav ul li a {
-  display: flex;
-  align-items: center;
-  height: 100%;
-  padding: 10px 20px;
-  color: #96a5ae;
-  font-weight: 800;
-  text-decoration: none;
-}
-main header nav ul li .submenu {
-  visibility: hidden;
-  overflow: hidden;
-  position: absolute;
-  height: auto;
-  max-height: 0;
-  transition: max-height 0.6s;
-  top: 100%;
-  left: 50%;
-  flex-direction: column;
-  background-color: white;
-  white-space: nowrap;
-}
-main header nav ul li.active a {
-  color: #3c4144;
-  border-bottom: 3px solid;
-}
-main header nav ul li:hover a {
-  color: #3c4144;
-}
-main header nav ul li:hover .submenu {
-  visibility: unset;
-  max-height: 1000px;
-}
-main header nav ul li:hover .submenu a {
-  color: #96a5ae;
-  border: none;
-}
-main header nav ul li:hover .submenu li:hover a {
-  color: #3c4144;
-}
-main header nav ul li.lang-flags {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin-left: auto;
-  padding: 0 20px;
-}
-main header nav ul li.lang-flags img {
-  width: 35px;
-  height: 30px;
-  cursor: pointer;
-  opacity: 0.5;
-}
-main header nav ul li.lang-flags img.selected, main header nav ul li.lang-flags img:hover {
-  opacity: 1;
-}
-main header nav .burger {
-  display: none;
-}
-@media screen and (max-width: 560px) {
-  main header nav {
-    justify-content: space-between;
-  }
-  main header nav .burger {
-    display: flex;
-    justify-content: center;
-    align-items: center;
-    flex-direction: row;
-    flex-direction: column;
-    font-weight: bold;
-    border: 1px solid;
-    margin: 0 20px;
-    cursor: pointer;
-    border-radius: 100%;
-    width: 35px;
-    height: 35px;
-    color: #555d61;
-    font-size: 25px;
-  }
-  main header nav ul {
-    display: none;
-  }
-  main header nav ul.responsive-show {
-    display: flex;
-    flex-direction: column;
-    position: absolute;
-    right: 0;
-    max-width: 100vw;
-    min-width: 50vw;
-    top: 60px;
-    background-color: white;
-    box-shadow: 0 4px 6px 2px #0000000a;
-    height: unset;
-  }
-  main header nav ul.responsive-show li.active a {
-    border: none;
-  }
-  main header nav ul.responsive-show li .submenu {
-    display: flex;
-    visibility: visible;
-    position: relative;
-    height: unset;
-    max-height: unset;
-    transition: max-height 0.6s;
-    top: unset;
-    left: unset;
-    margin-left: 20px;
-  }
-  main header nav ul.responsive-show li .submenu li a {
-    font-weight: 400;
-    font-size: 14px;
-    color: #96a5ae;
-  }
-  main header nav ul.responsive-show li.lang-flags {
-    margin-left: unset;
-    justify-content: space-around;
-    padding: 20px;
-  }
-}
-main #page-container {
-  width: 100%;
-  flex: 1;
-}
-main #page-container .page-header {
-  background-image: url("/assets/images/wallpaper_binary.png");
-  padding: 50px 0;
-}
-main #page-container .page-header h1 {
-  padding: 15px 40px 0;
-  font-size: 25px;
-  color: #4baabb;
-  margin: 0 auto;
-}
-main #page-container .page-header p {
-  color: #72e3f0;
-  font-style: italic;
-  padding: 15px 40px 15px 100px;
-  margin: 0 auto;
-  font-size: 18px;
-}
-main #page-container .page-header p * {
-  color: #72e3f0;
-}
-main #page-container .page-header .big-logo {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  gap: 20px;
-  padding: 20px;
-}
-main #page-container .page-header .big-logo img {
-  width: 200px;
-  max-width: 100%;
-}
-main #page-container .page-header .big-logo img.logo-text {
-  width: 300px;
-  max-width: 100%;
-}
-main #page-container .page-header .logo {
-  padding-left: 30px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-}
-main #page-container .page-header .logo img {
-  width: 100%;
-}
-@media screen and (max-width: 560px) {
-  main #page-container .page-header h1 {
-    padding: 15px 20px 0;
-  }
-  main #page-container .page-header p {
-    padding: 20px 20px 30px 40px;
-    text-align: justify;
-  }
-  main #page-container .page-header .big-logo {
-    flex-direction: column;
-  }
-}
-main #page-container .page-header.logo-left .grid-wrapper {
-  display: grid;
-  grid-template-columns: 120px 1fr;
-  grid-template-rows: auto 1fr;
-}
-main #page-container .page-header.logo-left .grid-wrapper h1 {
-  width: 100%;
-}
-main #page-container .page-header.logo-left .grid-wrapper .logo {
-  grid-column: 1;
-  grid-row: 1;
-  width: 100%;
-}
-main #page-container .page-header.logo-left .grid-wrapper p {
-  margin: 0;
-  grid-column: 1/span 2;
-}
-@media screen and (max-width: 780px) {
-  main #page-container .page-header.logo-left .grid-wrapper h1 {
-    padding: 0 20px;
-  }
-  main #page-container .page-header.logo-left .grid-wrapper .logo {
-    padding: 0 20px;
-  }
-}
-main #page-container .page-philo {
-  background-image: url("/assets/images/wallpaper_binary.png");
-  padding: 120px 30px;
-}
-main #page-container .page-philo p {
-  width: 100%;
-  max-width: 600px;
-  font-size: 18px;
-  color: #aabbc8;
-  text-align: center;
-  font-style: italic;
-  font-weight: bold;
-}
-main #page-container .page-philo p * {
-  color: #aabbc8;
-}
-main #page-container .page-contents-center {
-  width: 1300px;
-  max-width: 100%;
-  margin: 0 auto;
-}
-@media screen and (max-width: 1300px) {
-  main #page-container .page-contents-center {
-    padding: 20px 20px 0;
-  }
-}
-main #page-container h2.page-section-title {
-  color: #4baabb;
-  padding: 20px 0 10px;
-  width: 1300px;
-  max-width: 100%;
-  margin: 0 auto;
-}
-@media screen and (max-width: 1300px) {
-  main #page-container h2.page-section-title {
-    padding: 20px 20px 0;
-  }
-}
-main #page-container .article-details {
-  grid-column: 1/span 2;
-}
-main #page-container .article-details h2 {
-  color: #6b7880;
-  margin: 0 10px;
-  padding: 10px 0 0;
-  font-size: 16px;
-}
-main #page-container .article-details ul.details-list {
-  margin: 10px;
-}
-main #page-container .article-details ul.details-list .detail {
-  display: grid;
-  grid-template-columns: 1fr auto;
-  gap: 20px;
-  font-size: 12px;
-  border-bottom: 1px solid #d4d9dd;
-  padding: 5px 0;
-}
-main #page-container .article-details ul.details-list .detail label {
-  font-weight: bold;
-  color: #6b7880;
-}
-main #page-container .article-details ul.details-list .detail .detail-value {
-  text-align: right;
-}
-main #page-container #home-page {
-  display: flex;
-  flex-direction: column;
-}
-main #page-container #home-page .section-title {
-  padding: 10px;
-  margin: 0;
-  color: #aabbc8;
-}
-main #page-container #home-page .page-header .philo-bubbles {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  flex-wrap: wrap;
-  gap: 40px;
-  margin: 30px 20px;
-}
-@media screen and (max-width: 780px) {
-  main #page-container #home-page .page-header .philo-bubbles {
-    gap: 20px;
-  }
-}
-main #page-container #home-page .page-header .philo-bubbles li {
-  border-radius: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  background-color: #d4d9dd;
-  width: 100px;
-  height: 100px;
-}
-main #page-container #home-page .page-header .philo-bubbles li * {
-  color: #6b7880;
-}
-@media screen and (max-width: 560px) {
-  main #page-container #home-page .page-header .philo-bubbles li {
-    width: 75px;
-    height: 75px;
-  }
-  main #page-container #home-page .page-header .philo-bubbles li * {
-    font-size: 12px;
-  }
-}
-main #page-container #home-page .page-header .philo-bubbles li:first-child {
-  background-color: #6b7880;
-}
-main #page-container #home-page .page-header .philo-bubbles li:first-child * {
-  color: white;
-}
-main #page-container #home-page .page-header .philo-bubbles li:last-child {
-  background-color: #35393c;
-}
-main #page-container #home-page .page-header .philo-bubbles li:last-child * {
-  color: #96a5ae;
-}
-main #page-container #home-page .poles {
-  display: grid;
-  grid-template-columns: 1fr 1fr 1fr;
-  gap: 30px;
-  padding: 100px 0;
-}
-main #page-container #home-page .poles .theme-card {
-  display: flex;
-  flex-direction: column;
-  width: 100%;
-  cursor: pointer;
-  transition: transform 0.3s;
-}
-main #page-container #home-page .poles .theme-card .card-img {
-  width: 100%;
-  height: 240px;
-  overflow: hidden;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  flex-direction: column;
-  position: relative;
-}
-main #page-container #home-page .poles .theme-card .card-img img {
-  position: absolute;
-  max-width: 100%;
-  height: 100%;
-  padding: 10px;
-}
-main #page-container #home-page .poles .theme-card .card-title h2 {
-  margin: 0;
-  text-align: center;
-  padding: 10px 20px;
-  color: #4baabb;
-  display: block;
-  background-color: white;
-}
-main #page-container #home-page .poles .theme-card .card-description {
-  flex: 1;
-  padding: 30px 20px;
-}
-main #page-container #home-page .poles .theme-card .card-description p {
-  margin: 0;
-  color: #4baabb;
-  text-align: center;
-}
-main #page-container #home-page .poles .theme-card:hover {
-  transform: scale(1.03);
-}
-main #page-container #home-page .kuadrado-values {
-  background-image: url("/assets/images/wallpaper_binary_light.png");
-  padding: 100px 0 120px;
-}
-main #page-container #home-page .kuadrado-values *:not(a, blue) {
-  color: #d4d9dd;
-}
-main #page-container #home-page .kuadrado-values h2 {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  margin: 0 auto 60px;
-  width: 120px;
-  height: 120px;
-  background-image: url("/assets/images/wallpaper_binary.png");
-  border-radius: 100%;
-  color: #72e3f0;
-}
-main #page-container #home-page .kuadrado-values ul.values-list {
-  display: grid;
-  grid-template-columns: repeat(3, 1fr);
-  gap: 30px;
-}
-main #page-container #home-page .kuadrado-values ul.values-list li {
-  background-image: url("/assets/images/wallpaper_binary.png");
-  padding: 30px 20px 40px;
-}
-main #page-container #home-page .kuadrado-values ul.values-list li h3 {
-  text-align: center;
-}
-main #page-container #home-page .kuadrado-values ul.values-list li p {
-  text-align: justify;
-}
-@media screen and (max-width: 900px) {
-  main #page-container #home-page .poles {
-    grid-template-columns: 1fr;
-    gap: 40px;
-  }
-  main #page-container #home-page .poles .theme-card {
-    transition: transform 0.3s;
-  }
-  main #page-container #home-page .poles .theme-card .card-img {
-    height: 300px;
-  }
-  main #page-container #home-page .poles .theme-card .card-img img {
-    min-width: unset;
-    height: 100%;
-  }
-  main #page-container #home-page .poles .theme-card .card-title h2 {
-    padding: 5px 20px;
-  }
-  main #page-container #home-page .poles .theme-card .card-description {
-    padding: 20px 30px;
-  }
-  main #page-container #home-page .poles .theme-card:hover {
-    transform: none;
-  }
-  main #page-container #home-page .kuadrado-values ul.values-list {
-    grid-template-columns: 1fr;
-  }
-}
-@media screen and (max-width: 1300px) {
-  main #page-container #home-page .poles {
-    padding: 20px;
-  }
-  main #page-container #home-page .articles-displayer {
-    padding: 0;
-  }
-}
-main #page-container #education-page h3.big {
-  font-size: 30px;
-}
-main #page-container #education-page .title-banner {
-  display: flex;
-  justify-content: flex-end;
-  flex-direction: column;
-  height: 20vw;
-  min-height: 250px;
-  background-image: url("/assets/images/popularization_banner.png");
-  background-size: cover;
-  background-repeat: no-repeat;
-  background-position: center;
-}
-main #page-container #education-page .title-banner h2 {
-  color: white;
-  font-size: 2.5em;
-  margin: 40px;
-  text-shadow: 0 0 6px #0003;
-}
-main #page-container #education-page .special-announcement {
-  background-color: #ffd000;
-}
-main #page-container #education-page .special-announcement .page-contents-center {
-  padding: 0 20px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-}
-main #page-container #education-page .special-announcement .page-contents-center p {
-  color: #555d61;
-  font-size: 20px;
-  font-weight: 600;
-  margin: 0;
-  padding: 40px 0;
-}
-main #page-container #education-page .edu-themes {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 50px;
-  font-family: monospace;
-  padding: 70px 0;
-}
-main #page-container #education-page .edu-themes .edu-theme {
-  display: grid;
-  grid-template-columns: auto 1fr;
-}
-main #page-container #education-page .edu-themes .edu-theme * {
-  border-style: dashed;
-  border-color: #00ff00;
-  border-width: 0 0 0 0;
-}
-main #page-container #education-page .edu-themes .edu-theme h3 {
-  color: #00ff00;
-  grid-row: 1;
-  margin: 0;
-  padding: 10px;
-  display: flex;
-  align-items: center;
-  border-width: 0 0 0 1px;
-}
-main #page-container #education-page .edu-themes .edu-theme img {
-  width: 100%;
-  grid-row: 1/span 2;
-  border-width: 1px 0 1px 1px;
-}
-main #page-container #education-page .edu-themes .edu-theme p {
-  text-align: justify;
-  color: #72e3f0;
-  grid-row: 2;
-  margin: 0;
-  padding: 10px 30px 0 10px;
-  border-width: 1px 1px 1px 0;
-}
-main #page-container #education-page .edu-themes .edu-theme p * {
-  color: #72e3f0;
-}
-main #page-container #education-page .practical-info {
-  padding: 50px 0;
-}
-main #page-container #education-page .practical-info .page-contents-center {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 50px;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block {
-  display: grid;
-  grid-template-rows: auto 1fr;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block .info-title {
-  color: #4baabb;
-  margin: 0;
-  border-bottom: 1px dashed #aabbc8;
-  border-left: 1px dashed #aabbc8;
-  padding: 10px;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block .info-body {
-  margin: 0;
-  padding: 20px 10px;
-  border-right: 1px dashed #aabbc8;
-  border-bottom: 1px dashed #aabbc8;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block ul {
-  display: flex;
-  flex-direction: column;
-  gap: 5px;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block ul li:not(.fullwidth) {
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-  gap: 10px;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block ul.tabled li span {
-  padding: 3px 0;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block ul.tabled li span:first-child {
-  font-weight: bold;
-  color: #6b7880;
-}
-main #page-container #education-page .practical-info .page-contents-center .info-block ul.tabled li span:last-child {
-  color: #4baabb;
-}
-@media screen and (max-width: 1200px) {
-  main #page-container #education-page .edu-themes {
-    grid-template-columns: 1fr;
-    gap: 30px;
-    padding: 70px 0;
-  }
-}
-@media screen and (max-width: 780px) {
-  main #page-container #education-page .practical-info .page-contents-center {
-    grid-template-columns: 1fr;
-    gap: 30px;
-  }
-  main #page-container #education-page .practical-info .page-contents-center .info-block .info-title {
-    border-top: 1px dashed #aabbc8;
-  }
-  main #page-container #education-page .practical-info .page-contents-center .info-block .info-body {
-    border-bottom: none;
-  }
-}
-@media screen and (max-width: 560px) {
-  main #page-container #education-page .edu-themes .edu-theme h3 {
-    border-width: 0 0 1px 1px;
-  }
-  main #page-container #education-page .edu-themes .edu-theme img {
-    max-width: 150px;
-    height: auto;
-    grid-row: 1;
-    border-width: 1px 0 0 1px;
-  }
-  main #page-container #education-page .edu-themes .edu-theme p {
-    grid-row: 2;
-    grid-column: 1/span 2;
-    padding: 20px 10px 30px 10px;
-    border-width: 0 1px 1px 1px;
-  }
-}
-main #page-container #games-page .game-articles article {
-  display: grid;
-  grid-template-columns: 0.7fr 1fr;
-  gap: 30px 50px;
-  margin: 20px 0;
-}
-main #page-container #games-page .game-articles article.game-article {
-  grid-template-rows: repeat(7, auto);
-  width: 100%;
-}
-main #page-container #games-page .game-articles article.game-article .game-title {
-  grid-column: 1/span 2;
-  margin: 0;
-  padding: 30px 20px;
-  color: #aabbc8;
-  font-size: 35px;
-  font-style: italic;
-}
-main #page-container #games-page .game-articles article.game-article .game-banner {
-  grid-column: 1/span 2;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  flex-direction: column;
-  background-color: black;
-  overflow: hidden;
-  height: 300px;
-}
-main #page-container #games-page .game-articles article.game-article .game-banner img {
-  width: 100%;
-}
-main #page-container #games-page .game-articles article.game-article .game-tags {
-  display: flex;
-  gap: 10px;
-  flex-wrap: wrap;
-  margin: 10px 20px;
-}
-main #page-container #games-page .game-articles article.game-article .game-tags span {
-  font-size: 12px;
-  padding: 4px;
-  background-color: #d4d9dd;
-  color: #6b7880;
-  border-radius: 5px;
-  font-weight: 600;
-}
-main #page-container #games-page .game-articles article.game-article .game-subtitle {
-  grid-column: 1;
-  margin: 10px 20px;
-  color: #6b7880;
-}
-main #page-container #games-page .game-articles article.game-article .game-description {
-  grid-column: 1;
-  text-align: justify;
-  margin: 10px 20px 30px;
-}
-main #page-container #games-page .game-articles article.game-article .image-carousel {
-  grid-column: 2;
-  grid-row: 3/span 4;
-  height: 400px;
-}
-main #page-container #games-page .game-articles article.placeholder {
-  height: 400px;
-}
-main #page-container #games-page .game-articles article.placeholder * {
-  background-color: #d4d9dd;
-}
-@media screen and (max-width: 900px) {
-  main #page-container #games-page .game-articles article {
-    grid-template-columns: 1fr;
-  }
-  main #page-container #games-page .game-articles article.game-article {
-    grid-template-rows: repeat(6, auto);
-  }
-  main #page-container #games-page .game-articles article.game-article .game-title {
-    grid-column: 1;
-    padding: 0;
-    font-size: 25px;
-  }
-  main #page-container #games-page .game-articles article.game-article .game-banner {
-    grid-column: 1;
-    margin: 0 -20px;
-    height: 200px;
-  }
-  main #page-container #games-page .game-articles article.game-article .image-carousel {
-    grid-column: 1;
-    grid-row: 3;
-    margin: 0 -20px;
-  }
-}
-main #page-container #games-page .game-articles article .play-button {
-  border: none;
-  background-color: unset;
-  font-weight: bold;
-  font-size: 20px;
-  cursor: pointer;
-  color: #4baabb;
-}
-main #page-container #games-page .game-articles article .play-button:hover {
-  color: #72e3f0;
-}
-main #page-container #software-page .software-articles {
-  margin: 20px auto 50px;
-}
-main #page-container #software-page .software-articles article.software-article {
-  display: grid;
-  grid-template-columns: auto 1fr;
-  margin: 0 0 50px;
-  gap: 10px 30px;
-}
-main #page-container #software-page .software-articles article.software-article .software-title {
-  grid-column: 2;
-  color: #aabbc8;
-  margin: 0;
-  padding: 10px;
-}
-main #page-container #software-page .software-articles article.software-article .software-subtitle {
-  grid-column: 2;
-  margin: 10px;
-  color: #6b7880;
-}
-main #page-container #software-page .software-articles article.software-article .software-description {
-  grid-column: 2;
-  text-align: justify;
-  margin: 10px;
-}
-main #page-container #software-page .software-articles article.software-article .software-image {
-  padding: 20px;
-  background-color: black;
-  grid-column: 1;
-  grid-row: 1/span 3;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  width: 200px;
-  height: 200px;
-  overflow: hidden;
-  border-radius: 100%;
-}
-main #page-container #software-page .software-articles article.software-article .software-image img {
-  max-width: 100%;
-  max-height: 400px;
-}
-@media screen and (max-width: 900px) {
-  main #page-container #software-page .software-articles article.software-article .software-title {
-    display: flex;
-    align-items: center;
-  }
-  main #page-container #software-page .software-articles article.software-article .software-subtitle,
-main #page-container #software-page .software-articles article.software-article .software-description {
-    grid-column: 1/span 2;
-  }
-  main #page-container #software-page .software-articles article.software-article .software-image {
-    width: 100px;
-    height: 100px;
-    grid-row: 1;
-  }
-}
-main #page-container #software-page .software-articles article.placeholder {
-  display: flex;
-  flex-direction: column;
-  gap: 10px;
-  margin: 30px;
-}
-main #page-container #software-page .software-articles article.placeholder * {
-  background-color: #d4d9dd;
-}
-main #page-container #software-page .software-articles article.placeholder .title {
-  height: 60px;
-}
-main #page-container #software-page .software-articles article.placeholder .body {
-  height: 400px;
-}
-main #page-container #software-page .software-articles article.placeholder .details {
-  height: 200px;
-}
-main footer {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  flex-direction: column;
-  width: 100%;
-  background-image: url("/assets/images/wallpaper_binary.png");
-  padding: 40px 20px;
-  gap: 20px;
-  font-size: 12px;
-}
-main footer span {
-  color: #96a5ae;
-  text-align: center;
-}
-main footer .logo {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  gap: 10px;
-}
-main footer .logo img {
-  width: 35px;
-}
-main footer .logo img.text-logo {
-  width: 100px;
-}
-main footer .social {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  gap: 20px;
-}
-main footer .social a {
-  background-color: #555d61;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  flex-direction: row;
-  width: 25px;
-  height: 25px;
-  font-weight: bold;
-  font-size: 16px;
-  border-radius: 100%;
-}
-
-/*# sourceMappingURL=style.css.map */
diff --git a/website/package.json b/website/package.json
index e40a07a033d61aa39118cc1e532cbe831585b64f..89c51cf5432422b651fc5d25c82ceb6a0625703d 100644
--- a/website/package.json
+++ b/website/package.json
@@ -5,9 +5,9 @@
     "main": "src/main.js",
     "scripts": {
         "test": "echo \"Error: no test specified\" && exit 1",
-        "style-watch": "sass --watch ./src/style.scss ./public/style/style.css",
+        "style-watch": "sass --watch ./src/style.scss ../public/style/style.css",
         "build": "node build.js && sass ./src/style.scss ../public/style/style.css",
-        "build-prod": "node build.js prod && sass ./src/style.scss ./public/style/style.css --style=compressed"
+        "build-prod": "node build.js prod && sass ./src/style.scss ../public/style/style.css --style=compressed"
     },
     "repository": "https://gitlab.com/peter_rabbit/kuadrado-website",
     "author": "Kuadrado",
@@ -22,4 +22,4 @@
         "sass": "^1.32.0",
         "simple-browser-js-bundler": "^0.1.1"
     }
-}
+}
\ No newline at end of file
diff --git a/website/src/pages/education/components/edu-article.js b/website/src/pages/education/components/edu-article.js
new file mode 100644
index 0000000000000000000000000000000000000000..ab035167a8d5f31ec639d9b53731ba04cb2a4df3
--- /dev/null
+++ b/website/src/pages/education/components/edu-article.js
@@ -0,0 +1,84 @@
+"use strict";
+
+const { images_url } = require("../../../../constants");
+const ImageCarousel = require("../../../generic-components/image-carousel");
+const { getArticleBody } = require("../../../lib/article-utils");
+
+class EduArticle {
+    constructor(props) {
+        this.props = props;
+    }
+
+    render() {
+        const { title, body, subtitle, images, details = [] } = this.props;
+
+        return {
+            tag: "article",
+            class: "edu-article",
+            typeof: "Article",
+            contents: [
+                {
+                    tag: "h2",
+                    class: "edu-art-title",
+                    contents: title,
+                    property: "name",
+                },
+                {
+                    tag: "div", class: "edu-art-image",
+                    contents: [
+                        {
+                            tag: "img", src: `${images_url}/${images[0]}`
+                        }
+                    ]
+                },
+                {
+                    tag: "h3",
+                    class: "edu-art-subtitle",
+                    contents: subtitle,
+                    property: "alternativeHeadline",
+                },
+                {
+                    tag: "div",
+                    class: "edu-art-description",
+                    contents: getArticleBody(body),
+                    property: "description",
+                },
+                images.length > 1 && {
+                    tag: "div", class: "edu-art-carousel", contents: [
+                        new ImageCarousel({ images: images.map(img => `${images_url}/${img}`) }).render()
+                    ]
+                },
+                details.length > 0 && {
+                    tag: "div",
+                    class: "article-details edu-art-details",
+                    contents: [
+                        {
+                            tag: "h2",
+                            contents: "Details",
+                        },
+                        {
+                            tag: "ul",
+                            class: "details-list",
+                            contents: details.map(detail => {
+                                return {
+                                    tag: "li",
+                                    class: "detail",
+                                    contents: [
+                                        { tag: "label", contents: detail.label },
+                                        {
+                                            tag: "div",
+                                            class: "detail-value",
+                                            contents: detail.value
+                                        },
+                                    ],
+                                };
+                            }),
+                        },
+                    ],
+                },
+            ],
+        };
+    }
+}
+
+module.exports = EduArticle;
\ No newline at end of file
diff --git a/website/src/pages/education/components/edu-articles.js b/website/src/pages/education/components/edu-articles.js
new file mode 100644
index 0000000000000000000000000000000000000000..0c2ba4ede0f4ef12ec8349eea87a9538f085580b
--- /dev/null
+++ b/website/src/pages/education/components/edu-articles.js
@@ -0,0 +1,60 @@
+"use strict";
+const { loadArticles } = require("../../../lib/article-utils");
+const translator = require("ks-cheap-translator");
+const t = translator.trad.bind(translator);
+const EduArticle = require("./edu-article");
+
+class EduArticles {
+    constructor(props) {
+        this.props = props;
+        this.state = {
+            articles: [],
+            loaded: false,
+        };
+        this.id = "edu-articles-section";
+        this.loadArticles();
+    }
+
+    loadArticles() {
+        loadArticles("education", translator.locale)
+            .then(articles => {
+                this.state.articles = articles;
+            })
+            .catch(e => console.log(e))
+            .finally(() => {
+                this.state.loaded = true;
+                this.refresh()
+            });
+    }
+
+    renderPlaceholder() {
+        return {
+            tag: "article",
+            class: "placeholder",
+            contents: [{ tag: "div" }, { tag: "div" }, { tag: "div" }, { tag: "div" }],
+        };
+    }
+
+    refresh() {
+        obj2htm.subRender(this.render(), document.getElementById(this.id), {
+            mode: "replace",
+        });
+    }
+
+    render() {
+        const { articles, loaded } = this.state;
+        return {
+            tag: "section",
+            class: "edu-articles page-contents-center",
+            id: this.id,
+            contents:
+                loaded && articles.length > 0
+                    ? articles.map(article => new EduArticle({ ...article }).render())
+                    : loaded && articles.length === 0 ? [
+                        { tag: "p", contents: t("Rien de prévu pour le moment") }
+                    ] : [this.renderPlaceholder()],
+        };
+    }
+}
+
+module.exports = EduArticles;
\ No newline at end of file
diff --git a/website/src/pages/education/education.js b/website/src/pages/education/education.js
index efd67647628720b8b56333c6fda7f27620280728..ea04c92fc6a77ecb5ca8fae6357eb96702481daa 100644
--- a/website/src/pages/education/education.js
+++ b/website/src/pages/education/education.js
@@ -3,6 +3,7 @@
 const { images_url } = require("../../../constants");
 const WebPage = require("../../lib/web-page");
 const translator = require("ks-cheap-translator");
+const EduArticles = require("./components/edu-articles");
 const t = translator.trad.bind(translator);
 
 const EDU_THEMES = [
@@ -79,7 +80,6 @@ class EducationPage extends WebPage {
                 },
                 {
                     tag: "section",
-                    class: "bg-dark",
                     contents: [
                         {
                             tag: "div",
@@ -104,55 +104,8 @@ class EducationPage extends WebPage {
                         },
                     ]
                 },
-                {
-                    tag: "section",
-                    class: "practical-info",
-                    contents: [
-                        {
-                            tag: "div",
-                            class: "page-contents-center",
-                            contents: [
-                                {
-                                    tag: "div",
-                                    class: "info-block",
-                                    contents: [
-                                        { tag: "h3", class: "info-title", contents: `${t("Pour s'inscrire ou en savoir plus")} <em>(programme 2022 à définir, plus d'infos bientôt)</em>` },
-                                        {
-                                            tag: "ul",
-                                            class: "info-body",
-                                            contents: [
-                                                {
-                                                    tag: "li",
-                                                    contents: [
-                                                        { tag: "span", contents: t("Me contacter") },
-                                                        {
-                                                            tag: "a",
-                                                            href: "mailto:contact@kuadrado-software.fr",
-                                                            contents: "contact@kuadrado-software.fr",
-                                                        }
-                                                    ]
-                                                },
-                                                {
-                                                    tag: "li",
-                                                    contents: [
-                                                        { tag: "span", contents: "" },
-                                                        {
-                                                            tag: "a",
-                                                            href: "tel:+33475780872",
-                                                            contents: "04 75 78 08 72",
-                                                            property: "telephone",
-                                                        },
-                                                    ]
-                                                },
-                                            ]
-                                        }
-                                    ]
-                                }
-                            ]
-                        }
-                    ]
-
-                },
+                { tag: "h2", class: "edu-section-title page-contents-center", contents: t("Programme XXXX", { date: "2022" }) },
+                new EduArticles().render(),
             ],
         };
     }
diff --git a/website/src/pages/education/education.scss b/website/src/pages/education/education.scss
index d284dfc40d3e47226193841d2862e08ecfb48887..68712327924f4c950f1560fd1bde6bbffbed7202 100644
--- a/website/src/pages/education/education.scss
+++ b/website/src/pages/education/education.scss
@@ -1,177 +1,247 @@
 #education-page {
-    h3 {
-        &.big {
-            font-size: 30px;
-        }
-    }
-    .title-banner {
-        display: flex;
-        justify-content: flex-end;
-        flex-direction: column;
-        height: 20vw;
-        min-height: 250px;
-        background-image: url("/assets/images/popularization_banner.png");
-        background-size: cover;
-        background-repeat: no-repeat;
-        background-position: center;
-        h2 {
-            color: white;
-            font-size: 2.5em;
-            margin: 40px;
-            text-shadow: 0 0 6px #0003;
-        }
-    }
-    .special-announcement {
-        background-color: $yellow_2;
-        .page-contents-center {
-            padding: 0 20px;
-            @include flex-center;
-            p {
-                color: $dark_3;
-                font-size: 20px;
-                font-weight: 600;
-                margin: 0;
-                padding: 40px 0;
-            }
-        }
-    }
-
-    .edu-themes {
-        display: grid;
-        grid-template-columns: 1fr 1fr;
-        gap: 50px;
-        font-family: monospace;
-        padding: 70px 0;
-        .edu-theme {
-            display: grid;
-            grid-template-columns: auto 1fr;
-            * {
-                border-style: dashed;
-                border-color: $green;
-                border-width: 0 0 0 0;
-            }
-            h3 {
-                color: $green;
-                grid-row: 1;
-                margin: 0;
-                padding: 10px;
-                display: flex;
-                align-items: center;
-                border-width: 0 0 0 1px;
-            }
-            img {
-                width: 100%;
-                grid-row: 1 / span 2;
-                border-width: 1px 0 1px 1px;
-            }
-            p {
-                text-align: justify;
-                color: $blue_3;
-                * {
-                    color: $blue_3;
-                }
-                grid-row: 2;
-                margin: 0;
-                padding: 10px 30px 0 10px;
-                border-width: 1px 1px 1px 0;
-            }
-        }
-    }
-
-    .practical-info {
-        padding: 50px 0;
-        .page-contents-center {
-            display: grid;
-            grid-template-columns: 1fr 1fr;
-            gap: 50px;
-            .info-block {
-                display: grid;
-                grid-template-rows: auto 1fr;
-                .info-title {
-                    color: $blue_2;
-                    margin: 0;
-                    border-bottom: 1px dashed $light_2;
-                    border-left: 1px dashed $light_2;
-                    padding: 10px;
-                }
-                .info-body {
-                    margin: 0;
-                    padding: 20px 10px;
-                    border-right: 1px dashed $light_2;
-                    border-bottom: 1px dashed $light_2;
-                }
-                ul {
-                    display: flex;
-                    flex-direction: column;
-                    gap: 5px;
-                    li:not(.fullwidth) {
-                        display: grid;
-                        grid-template-columns: 1fr 1fr;
-                        gap: 10px;
-                    }
-                    &.tabled {
-                        li {
-                            span {
-                                padding: 3px 0;
-                                &:first-child {
-                                    font-weight: bold;
-                                    color: $medium_grey;
-                                }
-                                &:last-child {
-                                    color: $blue_2;
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-
-    @media screen and (max-width: 1200px) {
-        .edu-themes {
-            grid-template-columns: 1fr;
-            gap: 30px;
-            padding: 70px 0;
-        }
-    }
-
-    @media screen and (max-width: $screen_m) {
-        .practical-info {
-            .page-contents-center {
-                grid-template-columns: 1fr;
-                gap: 30px;
-                .info-block {
-                    .info-title {
-                        border-top: 1px dashed $light_2;
-                    }
-                    .info-body {
-                        border-bottom: none;
-                    }
-                }
-            }
-        }
-    }
-
-    @media screen and (max-width: $screen_s) {
-        .edu-themes {
-            .edu-theme {
-                h3 {
-                    border-width: 0 0 1px 1px;
-                }
-                img {
-                    max-width: 150px;
-                    height: auto;
-                    grid-row: 1;
-                    border-width: 1px 0 0 1px;
-                }
-                p {
-                    grid-row: 2;
-                    grid-column: 1 / span 2;
-                    padding: 20px 10px 30px 10px;
-                    border-width: 0 1px 1px 1px;
-                }
-            }
-        }
-    }
+	h3 {
+		&.big {
+			font-size: 30px;
+		}
+	}
+	.title-banner {
+		display: flex;
+		justify-content: flex-end;
+		flex-direction: column;
+		height: 20vw;
+		min-height: 250px;
+		background-image: url("/assets/images/popularization_banner.png");
+		background-size: cover;
+		background-repeat: no-repeat;
+		background-position: center;
+		h2 {
+			color: white;
+			font-size: 2.5em;
+			margin: 40px;
+			text-shadow: 0 0 6px #0003;
+		}
+	}
+	.special-announcement {
+		background-color: $yellow_2;
+		.page-contents-center {
+			padding: 0 20px;
+			@include flex-center;
+			p {
+				color: $dark_3;
+				font-size: 20px;
+				font-weight: 600;
+				margin: 0;
+				padding: 40px 0;
+			}
+		}
+	}
+
+	.edu-section-title {
+		color: $blue_2;
+		margin: 40px auto;
+		display: flex;
+		align-items: center;
+		white-space: nowrap;
+		gap: 15px;
+		&::after {
+			content: "";
+			display: block;
+			flex: 1;
+			border: 1px solid;
+		}
+	}
+
+	.edu-themes {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		gap: 50px;
+		font-family: monospace;
+		padding: 40px 0;
+		.edu-theme {
+			display: grid;
+			grid-template-columns: auto 1fr;
+			h3 {
+				color: $blue_2;
+				grid-row: 1;
+				margin: 0;
+				padding: 10px;
+				display: flex;
+				align-items: center;
+			}
+			img {
+				width: 100%;
+				grid-row: 1 / span 2;
+			}
+			p {
+				text-align: justify;
+				grid-row: 2;
+				margin: 0;
+				padding: 10px 30px 0 10px;
+			}
+		}
+	}
+
+	.edu-articles {
+		display: flex;
+		flex-direction: column;
+		gap: 40px;
+		margin: 20px auto;
+		article.edu-article {
+			display: grid;
+			grid-template-columns: auto 1fr auto;
+			gap: 10px 30px;
+			ul {
+				list-style-type: unset;
+				margin-left: 30px;
+			}
+
+			.edu-art-image {
+				padding: 20px;
+				background-color: black;
+				grid-column: 1;
+				grid-row: 1 / span 3;
+				@include flex-center;
+				width: 150px;
+				height: 150px;
+				overflow: hidden;
+				border-radius: 100%;
+				img {
+					max-width: 100%;
+					max-height: 400px;
+				}
+			}
+
+			.edu-art-title {
+				grid-column: 2 / span 3;
+				color: $light_2;
+				margin: 0;
+				padding: 10px;
+			}
+
+			.edu-art-subtitle {
+				grid-column: 2 / span 3;
+				margin: 10px;
+				color: $medium_grey;
+			}
+
+			.edu-art-description {
+				grid-column: 2;
+				text-align: justify;
+				margin: 10px;
+			}
+
+			.edu-art-carousel {
+				grid-column: 3;
+				width: 30vw;
+				max-width: 500px;
+				.image-carousel {
+					width: 100%;
+					height: 20vw;
+					max-height: 400px;
+					img {
+						width: 100%;
+					}
+				}
+			}
+
+			.edu-art-details {
+				grid-column: 1 / span 3;
+			}
+
+			@media screen and (max-width: 1050px) {
+				grid-template-columns: auto 1fr;
+				.edu-art-title {
+					grid-column: 2;
+				}
+				.edu-art-image {
+					grid-row: 1 / span 2;
+				}
+				.edu-art-carousel {
+					width: 100%;
+					max-width: unset;
+					grid-column: 1 / span 2;
+					grid-row: 3;
+					.image-carousel {
+						height: 40vw;
+						max-height: 400px;
+					}
+				}
+				.edu-art-subtitle {
+					grid-column: 2;
+					grid-row: 2;
+				}
+
+				.edu-art-description {
+					grid-column: 1 / span 2;
+				}
+
+				.edu-art-details {
+					grid-column: 1 / span 2;
+				}
+			}
+
+			@media screen and (max-width: $screen_l) {
+				.edu-art-title {
+					display: flex;
+					align-items: center;
+				}
+
+				.edu-art-subtitle {
+					grid-column: 1 / span 2;
+				}
+				.edu-art-image {
+					width: 100px;
+					height: 100px;
+					grid-row: 1;
+				}
+				// .edu-art-carousel {
+				// 	grid-column: 1 / span 2;
+				// }
+			}
+		}
+
+		article.placeholder {
+			display: grid;
+			grid-template-columns: 200px auto;
+			grid-template-rows: 100px 100px 200px;
+			gap: 10px;
+			margin: 30px;
+			* {
+				background-color: $light_0;
+				flex: 1;
+				&:first-child {
+					// image
+					grid-row: 1 / span 2;
+				}
+				&:last-child {
+					grid-column: 1 / span 2;
+				}
+			}
+		}
+	}
+
+	@media screen and (max-width: 1200px) {
+		.edu-themes {
+			grid-template-columns: 1fr;
+			gap: 30px;
+			padding: 0;
+		}
+	}
+
+	@media screen and (max-width: $screen_s) {
+		.edu-themes {
+			.edu-theme {
+				img {
+					max-width: 150px;
+					height: auto;
+					grid-row: 1;
+				}
+				p {
+					grid-row: 2;
+					grid-column: 1 / span 2;
+					padding: 20px 10px 30px 10px;
+				}
+			}
+		}
+	}
 }
diff --git a/website/src/style.scss b/website/src/style.scss
index e3ca9c780a5ec05aec8bccf62914ffa7717c492b..d523c3b1c5cf1ad8d95e59a0db629dc2e449f9c8 100644
--- a/website/src/style.scss
+++ b/website/src/style.scss
@@ -413,6 +413,7 @@ main {
 					label {
 						font-weight: bold;
 						color: $medium_grey;
+						white-space: nowrap;
 					}
 					.detail-value {
 						text-align: right;