Improve Debug web app.
authorBenjamin Braatz <bb@bbraatz.eu>
Tue, 23 Mar 2021 12:43:19 +0000 (13:43 +0100)
committerBenjamin Braatz <bb@bbraatz.eu>
Tue, 23 Mar 2021 12:43:19 +0000 (13:43 +0100)
conf.json
controlpi_plugins/web/Debug/controlpi-debug.css
controlpi_plugins/web/Debug/controlpi-debug.js
controlpi_plugins/web/Debug/index.css
web/index.css

index f24a053ada7ca9b6445213342e0d7b1f0a6a738c..adf56e12c556f94c948e08c0cdb3661650e0a679 100644 (file)
--- a/conf.json
+++ b/conf.json
@@ -24,7 +24,7 @@
         "to": {
             "target": "Waiter",
             "command": "wait",
-            "seconds": 5.0,
+            "seconds": 3.0,
             "id": "off delay"
         }
     },
@@ -32,6 +32,7 @@
         "plugin": "Alias",
         "from": {
             "sender": { "const": "Waiter" },
+            "event": { "const": "finished" },
             "id": { "const": "off delay" }
         },
         "to": {
index 82ca93b78e3282c6cc0689358a66caaf7d62be46..ac85cc171631ff1df29aad58846ea599d9fb6f8b 100644 (file)
+/* ************************************************** */
+/* Client container, clients and interface containers */
+/* ************************************************** */
 .clientcontainer {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
     padding-right: 5px;
     padding-bottom: 5px;
 }
-
-.client {
-    display: inline-block;
-    vertical-align: top;
-    width: 600px;
+.clientcontainer > * {
     margin-top: 5px;
     margin-left: 5px;
-    border: 2px solid black;
-    padding: 5px;
-    background-color: #bcdaf8;
 }
 
+.client {
+    background-color: #bcdaf8;
+    border: 2px solid black;
+    padding-right: 5px;
+    padding-bottom: 5px;
+}
 .client > header {
     display: flex;
     justify-content: space-between;
 }
 
-.client > header > h2 {
-    vertical-align: top;
-    font-size: 1.2rem;
-    font-weight: bold;
-}
-
-.client > header > h3 {
-    vertical-align: top;
-    font-size: 0.8rem;
-    font-weight: bold;
-}
-
 .interfacecontainer {
-    white-space: nowrap;
+    display: flex;
+    flex-direction: row;
 }
-
-.interfacecontainer > h3 {
-    display: inline-block;
-    vertical-align: top;
-    width: 20px;
+.interfacecontainer > :first-child {
     margin-top: 5px;
-    font-weight: bold;
+    margin-left: 5px;
 }
 
+/* ********************************************* */
+/* Containers for message templates and messages */
+/* ********************************************* */
+.templatecontainer, .lastcontainer {
+    display: flex;
+    flex-direction: row;
+}
 .templatecontainer {
-    display: inline-block;
-    vertical-align: top;
-    width: 580px;
-    white-space: normal;
+    flex-wrap: wrap;
 }
-
 .lastcontainer {
-    text-align: center;
+    justify-content: center;
 }
-
-.lastcontainer * {
-    text-align: left;
+.templatecontainer > *, .lastcontainer > * {
+    margin-top: 5px;
+    margin-left: 5px;
+}
+.templatecontainer > *, .inputsizespan {
+    font-size: 0.8rem;
 }
 
-.message {
-    display: inline-block;
-    vertical-align: top;
+/* *********************** */
+/* JSON objects and arrays */
+/* *********************** */
+.object {
+    display: flex;
+    flex-direction: column;
     background-color: #79b5e7;
     border: 1px solid black;
+    padding-right: 2px;
+    padding-bottom: 2px;
 }
-
-.message.green {
+.object.green {
     background-color: #85be71;
 }
-
-.message.red {
+.object.red {
     background-color: #ee96a8;
 }
-
-.templatecontainer > .message {
-    font-size: 0.8rem;
+.object > h2 {
     margin-top: 5px;
     margin-left: 5px;
+    margin-right: 3px;
+    margin-bottom: 3px;
 }
-
-.lastcontainer > .message {
-    margin-top: 5px;
-}
-
-.message > span {
-    margin: 5px;
-    font-size: 1rem;
-}
-
-.message > h4 {
-    margin: 2px;
-    font-size: 0.8rem;
-}
-
-.message table {
-    border-spacing: 2px;
+.object > h4 {
+    margin-top: 2px;
+    margin-left: 2px;
 }
 
-.message .array {
+.property {
     display: flex;
     flex-direction: row;
-    flex-wrap: wrap;
 }
-
-.message .array > * {
-    vertical-align: baseline;
+.property > .key, .property > .value, .property > .object {
+    margin-top: 2px;
+    margin-left: 2px;
 }
 
-.message .object {
-    display: flex;
-    flex-direction: column;
-    border: 1px solid black;
-    padding-top: 2px;
-    padding-left: 2px;
-    margin-bottom: 2px;
-}
-
-.message .object > .property {
+.array {
     display: flex;
     flex-direction: row;
     flex-wrap: wrap;
 }
 
-.message .object > .property > .key {
-    margin-right: 2px;
-    margin-bottom: 2px;
-}
-
-.message .object > .property > .value {
-    display: flex;
-    flex-direction: row;
-    flex-wrap: wrap;
-    margin-right: 2px;
-    margin-bottom: 2px;
+/* TODO: Remove and use property-key-value structure: */
+.object table {
+    border-spacing: 2px;
 }
 
-.message input {
+/* ************************** */
+/* Form elements for commands */
+/* ************************** */
+input, select {
     background-color: #35bfd3;
 }
-
-.message input[readonly] {
+input::selection {
+    background-color: #d69ae2;
+}
+input[readonly] {
     background-color: #79b5e7;
 }
-
-.message input:hover,
-.message input:focus {
+input:hover, input:focus, select:hover, select:focus {
     background-color: #afa7ec;
 }
 
-.message input::selection {
-    background-color: #d69ae2;
-}
-
-.message input[type="submit"] {
+input[type="submit"] {
     border: 1px solid #0f4a53;
     border-radius: 12px;
     padding: 2px;
 }
-
-.message input[type="submit"]:hover,
-.message input[type="submit"]:focus {
+input[type="submit"]:hover, input[type="submit"]:focus {
     border: 1px solid #44405e;
 }
 
-.message input[type="text"],
-.message input[type="number"] {
+input[type="text"], input[type="number"] {
     min-width: 5px;
 }
 
@@ -175,12 +138,3 @@ input[type="number"]::-webkit-inner-spin-button {
 input[type="number"] {
     -moz-appearance: textfield;
 }
-
-.message select {
-    background-color: #35bfd3;
-}
-
-.message select:hover,
-.message select:focus {
-    background-color: #afa7ec;
-}
index 3cfe956b5e5fdc3d619e76cadd350017bc6084e7..d7650d04ca75cb084e93a32ca74021c3443a1561 100644 (file)
@@ -1,5 +1,75 @@
+// Open Websocket back to ControlPi we were loaded from:
+const websocket = new WebSocket("ws://" + window.location.host)
+
+// When Websocket is ready request all clients from bus:
+websocket.addEventListener('open', function (event) {
+    websocket.send(JSON.stringify({target: '', command: 'get clients'}))
+})
+
+// When receiving message from ControlPi through Websocket:
+websocket.addEventListener('message', function (event) {
+    const message = JSON.parse(event.data)
+    if (message['sender'] == '') {
+        processBusMessage(message)
+    } else {
+        processClientMessage(message)
+    }
+})
+
+// Remove (if unregistered) or create (if not existent) elements for
+// clients and update interface information:
+function processBusMessage(message) {
+    if (message['event'] == 'unregistered') {
+        // On deregistration delete client element if it exists:
+        const clientElement = document.getElementById(message['client'])
+        if (clientElement != null) {
+            clientElement.remove()
+        }
+    } else {
+        // On registration or 'get clients' answer:
+        const clientElement = document.getElementById(message['client'])
+        if (clientElement == null) {
+            // Create element for client if not existent:
+            const main = document.getElementById('ControlPi Debug')
+            main.appendChild(createClient(message['client'],
+                                             message['plugin']))
+        }
+        // Crate message elements for receives interface:
+        const receiveContainer = document.getElementById(message['client'] + ' Receives')
+        receiveContainer.innerHTML = ''
+        for (const template of message['receives']) {
+            if (template['command'] != null) {
+                receiveContainer.appendChild(createForCommand(template))
+            } else {
+                receiveContainer.appendChild(createObject(template, 'template'))
+            }
+        }
+        // Create message elements for sends interface:
+        const sendContainer = document.getElementById(message['client'] + ' Sends')
+        sendContainer.innerHTML = ''
+        for (const template of message['sends']) {
+            sendContainer.appendChild(createObject(template, 'template'))
+        }
+    }
+}
+
+// Create element for client (if not existent)
+// and update last received message:
+function processClientMessage(message) {
+    const clientElement = document.getElementById(message['sender'])
+    if (clientElement == null) {
+        // Create section for client if not existent:
+        const main = document.getElementById('ControlPi Debug')
+        main.appendChild(createClient(message.sender, ''))
+    }
+    // Update last received message:
+    const lastContainer = document.getElementById(message['sender'] + ' Last')
+    lastContainer.innerHTML = ''
+    lastContainer.appendChild(createObject(message, 'message'))
+}
+
 // Create section for client:
-function createForClient(client, plugin) {
+function createClient(client, plugin) {
     const section = document.createElement('section')
     section.setAttribute('id', client)
     section.setAttribute('class', 'client')
@@ -7,9 +77,9 @@ function createForClient(client, plugin) {
     const headingH2 = document.createElement('h2')
     headingH2.appendChild(document.createTextNode(client))
     heading.appendChild(headingH2)
-    const headingH3 = document.createElement('h3')
-    headingH3.appendChild(document.createTextNode(plugin))
-    heading.appendChild(headingH3)
+    const headingH4 = document.createElement('h4')
+    headingH4.appendChild(document.createTextNode(plugin))
+    heading.appendChild(headingH4)
     section.appendChild(heading)
     const receiveOuter = document.createElement('div')
     receiveOuter.setAttribute('class', 'interfacecontainer')
@@ -38,60 +108,152 @@ function createForClient(client, plugin) {
     return section
 }
 
-// Create div and table for template:
-function createForTemplate(template) {
-    const div = document.createElement('div')
-    div.setAttribute('class', 'message')
-    if (Object.keys(template).length === 0) {
-        // Create span with '*' for empty templates:
-        const span = document.createElement('span')
-        const spanContent = document.createTextNode('*')
-        span.appendChild(spanContent)
-        div.appendChild(span)
+// Create structure for arbirary JSON element:
+function create(thing, parentType) {
+    if (typeof(thing) == 'object') {
+        if (Array.isArray(thing)) {
+            return createArray(thing, parentType)
+        } else if (parentType == 'template') {
+            if ('const' in thing) {
+                return create(thing['const'])
+            }
+            if ('type' in thing) {
+                if (thing['type'] == 'object') {
+                    return createObject(thing, type)
+                } else {
+                    const valueDiv = document.createElement('div')
+                    valueDiv.setAttribute('class', 'value')
+                    valueDiv.appendChild(document.createTextNode(
+                        '<' + thing['type'] + '>'))
+                    return valueDiv
+                }
+            }
+        } else {
+            type = parentType
+            if (parentType == 'message') {
+                type = ''
+            }
+            return createObject(thing, type)
+        }
     } else {
-        const table = document.createElement('table')
-        for (const key in template) {
-            // Append table row for key-value pair:
-            const tr = document.createElement('tr')
-            const keyTd = document.createElement('td')
-            const keyTdContent = document.createTextNode(key + ':')
-            keyTd.appendChild(keyTdContent)
-            tr.appendChild(keyTd)
-            const valueTd = document.createElement('td')
-            schema = template[key]
-            value = ''
-            if ('const' in schema) {
-                value = JSON.stringify(schema['const'])
-            } else {
-                value = JSON.stringify(schema)
+        const valueDiv = document.createElement('div')
+        valueDiv.setAttribute('class', 'value')
+        valueDiv.appendChild(document.createTextNode(JSON.stringify(thing)))
+        return valueDiv
+    }
+}
+
+// Create structure for array:
+function createArray(array, parentType) {
+    arrayDiv = document.createElement('div')
+    arrayDiv.setAttribute('class', 'array')
+    arrayDiv.appendChild(document.createTextNode('['))
+    var counter = array.length
+    for (const item of array) {
+        arrayDiv.appendChild(create(item, parentType))
+        if (--counter) {
+            arrayDiv.appendChild(document.createTextNode(', '))
+        }
+    }
+    arrayDiv.appendChild(document.createTextNode(']'))
+}
+
+// Create structure for object:
+function createObject(object, type) {
+    const objectDiv = document.createElement('div')
+    objectDiv.setAttribute('class', 'object')
+    if (type == 'message') {
+        // Current (receive) time as heading:
+        const time = new Date().toLocaleTimeString()
+        const h4 = document.createElement('h4')
+        const h4Content = document.createTextNode(time)
+        h4.appendChild(h4Content)
+        objectDiv.appendChild(h4)
+    }
+    if (type == 'template') {
+        if (Object.keys(object).length === 0) {
+            // Create span with '*' for empty templates:
+            const wildcard = document.createElement('h2')
+            wildcard.appendChild(document.createTextNode('*'))
+            objectDiv.appendChild(wildcard)
+        }
+    }
+    for (const key in object) {
+        if (type == 'message') {
+            if (key == 'sender') {
+                // Ignore 'sender' for message
+                // (information redundantly present in client heading):
+                continue
+            } else if (key == 'state') {
+                // Set background according to state:
+                if (object[key] === true) {
+                    objectDiv.classList.add('green')
+                } else if (object[key] === false) {
+                    objectDiv.classList.add('red')
+                }
             }
-            const valueTdContent = document.createTextNode(value)
-            valueTd.appendChild(valueTdContent)
-            tr.appendChild(valueTd)
-            table.appendChild(tr)
         }
-        div.appendChild(table)
+        // Create property div:
+        const propertyDiv = document.createElement('div')
+        propertyDiv.setAttribute('class', 'property')
+        // Create key div and append to property div:
+        const keyDiv = document.createElement('div')
+        keyDiv.setAttribute('class', 'key')
+        keyDiv.appendChild(document.createTextNode(key + ':'))
+        propertyDiv.appendChild(keyDiv)
+        // Create value element and append to property div:
+        propertyDiv.appendChild(create(object[key], type))
+        // Append property div to object div:
+        objectDiv.append(propertyDiv)
     }
-    return div
+    return objectDiv
 }
 
-// Resize input fields to current input:
-function resizeInput() {
-    const tmp = document.createElement('span')
-    // HACK: Should get this from CSS rather than hardcode it:
-    tmp.setAttribute('style', 'font-size: 0.8rem;')
-    var value = this.value
-    // HACK: 'number' fields for floats do not return trailing dot in
-    // value, so we add one unconditionally:
-    if (this.type == 'number' && this.step == 'any' && !value.includes('.')) {
-        value += '.'
+// Create div, form and table for command template:
+function createForCommand(template) {
+    const div = document.createElement('div')
+    div.setAttribute('class', 'object')
+    const form = document.createElement('form')
+    form.addEventListener("submit", function(e) {
+        // Convert names and values of all form elements to key-value pairs
+        // and send as JSON over websocket:
+        e.preventDefault();
+        var data = {};
+        for (const element of form.elements) {
+            if (element.type == 'text' || element.type == 'hidden') {
+                data[element.name] = element.value
+            } else if (element.type == 'number') {
+                data[element.name] = element.valueAsNumber
+            } else if (element.type == 'select-one') {
+                if (element.value == 'true') {
+                    data[element.name] = true
+                } else if (element.value == 'false') {
+                    data[element.name] = false
+                } else {
+                    data[element.name] = element.value
+                }
+            }
+        }
+        websocket.send(JSON.stringify(data))
+    })
+    const table = document.createElement('table')
+    for (const key in template) {
+        // Append table row for key-value pair:
+        const tr = document.createElement('tr')
+        const keyTd = document.createElement('td')
+        const keyTdContent = document.createTextNode(key + ':')
+        keyTd.appendChild(keyTdContent)
+        tr.appendChild(keyTd)
+        const valueTd = document.createElement('td')
+        for (const element of inputsForKeyValue(key, template[key])) {
+            valueTd.appendChild(element)
+        }
+        tr.appendChild(valueTd)
+        table.appendChild(tr)
     }
-    const tmpContent = document.createTextNode(value)
-    tmp.appendChild(tmpContent)
-    document.body.appendChild(tmp);
-    const width = tmp.getBoundingClientRect().width
-    document.body.removeChild(tmp);
-    this.style.width = width + 'px'
+    form.appendChild(table)
+    div.appendChild(form)
+    return div
 }
 
 // Create form input (or select) for key-value pairs in receive templates:
@@ -190,196 +352,21 @@ function inputsForKeyValue(key, schema) {
     return result
 }
 
-// Create div, form and table for command template:
-function createForCommand(template) {
-    const div = document.createElement('div')
-    div.setAttribute('class', 'message')
-    const form = document.createElement('form')
-    form.addEventListener("submit", function(e) {
-        // Convert names and values of all form elements to key-value pairs
-        // and send as JSON over websocket:
-        e.preventDefault();
-        var data = {};
-        for (const element of form.elements) {
-            if (element.type == 'text' || element.type == 'hidden') {
-                data[element.name] = element.value
-            } else if (element.type == 'number') {
-                data[element.name] = element.valueAsNumber
-            } else if (element.type == 'select-one') {
-                if (element.value == 'true') {
-                    data[element.name] = true
-                } else if (element.value == 'false') {
-                    data[element.name] = false
-                } else {
-                    data[element.name] = element.value
-                }
-            }
-        }
-        websocket.send(JSON.stringify(data))
-    })
-    const table = document.createElement('table')
-    for (const key in template) {
-        // Append table row for key-value pair:
-        const tr = document.createElement('tr')
-        const keyTd = document.createElement('td')
-        const keyTdContent = document.createTextNode(key + ':')
-        keyTd.appendChild(keyTdContent)
-        tr.appendChild(keyTd)
-        const valueTd = document.createElement('td')
-        for (const element of inputsForKeyValue(key, template[key])) {
-            valueTd.appendChild(element)
-        }
-        tr.appendChild(valueTd)
-        table.appendChild(tr)
-    }
-    form.appendChild(table)
-    div.appendChild(form)
-    return div
-}
-
-// Remove (if unregistered) or create (if not existent) elements for
-// clients and update interface information:
-function processBusMessage(message) {
-    if (message['event'] == 'unregistered') {
-        // On deregistration delete client element if it exists:
-        const clientElement = document.getElementById(message['client'])
-        if (clientElement != null) {
-            clientElement.remove()
-        }
-    } else {
-        // On registration or 'get clients' answer:
-        const clientElement = document.getElementById(message['client'])
-        if (clientElement == null) {
-            // Create element for client if not existent:
-            const main = document.getElementById('ControlPi Debug')
-            main.appendChild(createForClient(message['client'],
-                                             message['plugin']))
-        }
-        // Crate message elements for receives interface:
-        const receiveContainer = document.getElementById(message['client'] + ' Receives')
-        receiveContainer.innerHTML = ''
-        for (const template of message['receives']) {
-            if (template['command'] != null) {
-                receiveContainer.appendChild(createForCommand(template))
-            } else {
-                receiveContainer.appendChild(createForTemplate(template))
-            }
-        }
-        // Create message elements for sends interface:
-        const sendContainer = document.getElementById(message['client'] + ' Sends')
-        sendContainer.innerHTML = ''
-        for (const template of message['sends']) {
-            sendContainer.appendChild(createForTemplate(template))
-        }
-    }
-}
-
-// Recursively create div structure for arrays, objects and primitive
-// values:
-function createForMessageValue(value) {
-    var result = ''
-    if (typeof(value) == 'object') {
-        if (Array.isArray(value)) {
-            result = document.createElement('div')
-            result.setAttribute('class', 'array')
-            var counter = value.length
-            for (const child of value) {
-                result.appendChild(createForMessageValue(child))
-                if (--counter) {
-                    result.appendChild(document.createTextNode(', '))
-                }
-            }
-        } else {
-            result = document.createElement('div')
-            result.setAttribute('class', 'object')
-            for (const key in value) {
-                const property = document.createElement('div')
-                property.setAttribute('class', 'property')
-                const keyDiv = document.createElement('div')
-                keyDiv.setAttribute('class', 'key')
-                keyDiv.appendChild(document.createTextNode(key + ':'))
-                property.appendChild(keyDiv)
-                const valueDiv = document.createElement('div')
-                valueDiv.setAttribute('class', 'value')
-                valueDiv.appendChild(createForMessageValue(value[key]))
-                property.appendChild(valueDiv)
-                result.appendChild(property)
-            }
-        }
-    } else {
-        result = document.createTextNode(JSON.stringify(value))
-    }
-    return result
-}
-
-// Create div and table for message:
-function createForMessage(message) {
-    const div = document.createElement('div')
-    div.setAttribute('class', 'message')
-    // Current (receive) time as heading:
-    const time = new Date().toLocaleTimeString()
-    const h4 = document.createElement('h4')
-    const h4Content = document.createTextNode(time)
-    h4.appendChild(h4Content)
-    div.appendChild(h4)
-    const table = document.createElement('table')
-    for (const key in message) {
-        if (key == 'sender') {
-            // Ignore 'sender' for last received messages
-            // (information redundantly present in client heading):
-            continue
-        } else if (key == 'state') {
-            // Set background according to state:
-            if (message[key] === true) {
-                div.classList.add('green')
-            } else if (message[key] === false) {
-                div.classList.add('red')
-            }
-        }
-        // Append table row for key-value pair:
-        const tr = document.createElement('tr')
-        const keyTd = document.createElement('td')
-        const keyTdContent = document.createTextNode(key + ':')
-        keyTd.appendChild(keyTdContent)
-        tr.appendChild(keyTd)
-        const valueTd = document.createElement('td')
-        valueTd.appendChild(createForMessageValue(message[key]))
-        tr.appendChild(valueTd)
-        table.appendChild(tr)
-    }
-    div.appendChild(table)
-    return div
-}
-
-// Create element for client (if not existent)
-// and update last received message:
-function processClientMessage(message) {
-    const clientElement = document.getElementById(message['sender'])
-    if (clientElement == null) {
-        // Create section for client if not existent:
-        const main = document.getElementById('ControlPi Debug')
-        main.appendChild(createForClient(message.sender, ''))
+// Resize input fields to current input:
+function resizeInput() {
+    const tmp = document.createElement('span')
+    tmp.setAttribute('class', 'inputsizespan')
+    // Space
+    var value = this.value.replace(/ /g, '\u00a0')
+    // HACK: 'number' fields for floats do not return trailing dot in
+    // value, so we add one unconditionally:
+    if (this.type == 'number' && this.step == 'any' && !value.includes('.')) {
+        value += '.'
     }
-    // Update last received message:
-    const lastContainer = document.getElementById(message['sender'] + ' Last')
-    lastContainer.innerHTML = ''
-    lastContainer.appendChild(createForMessage(message))
+    const tmpContent = document.createTextNode(value)
+    tmp.appendChild(tmpContent)
+    document.body.appendChild(tmp);
+    const width = tmp.getBoundingClientRect().width
+    document.body.removeChild(tmp);
+    this.style.width = width + 'px'
 }
-
-// Open Websocket back to ControlPi we were loaded from:
-const websocket = new WebSocket("ws://" + window.location.host)
-
-// When Websocket is ready request all clients from bus:
-websocket.addEventListener('open', function (event) {
-    websocket.send(JSON.stringify({target: '', command: 'get clients'}))
-})
-
-// When receiving message from ControlPi through Websocket:
-websocket.addEventListener('message', function (event) {
-    const message = JSON.parse(event.data)
-    if (message['sender'] == '') {
-        processBusMessage(message)
-    } else {
-        processClientMessage(message)
-    }
-})
index e5d2c50fa0c15ae4ae8e7febafd8c374563a0b01..e82d65ea0d4d0eae9dd101994e36bdc71d2db1b2 100644 (file)
@@ -52,7 +52,7 @@ body > header span.IT {
     color: #83a1b4;
 }
 
-h1, h2, h3 {
+h1, h2, h3, h4 {
     margin-top: 5px;
     margin-left: 5px;
     font-weight: bold;
@@ -67,5 +67,9 @@ h2 {
 }
 
 h3 {
-    font-size: 1.2rem;
+    font-size: 1.0rem;
+}
+
+h4 {
+    font-size: 0.8rem;
 }
index e5d2c50fa0c15ae4ae8e7febafd8c374563a0b01..e82d65ea0d4d0eae9dd101994e36bdc71d2db1b2 100644 (file)
@@ -52,7 +52,7 @@ body > header span.IT {
     color: #83a1b4;
 }
 
-h1, h2, h3 {
+h1, h2, h3, h4 {
     margin-top: 5px;
     margin-left: 5px;
     font-weight: bold;
@@ -67,5 +67,9 @@ h2 {
 }
 
 h3 {
-    font-size: 1.2rem;
+    font-size: 1.0rem;
+}
+
+h4 {
+    font-size: 0.8rem;
 }