Import code from hanafuda-webapp and adapt it for SJW

This commit is contained in:
Tissevert 2020-01-10 08:36:57 +01:00
commit f625b9954f
21 changed files with 1120 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
game.js
index.js
skin.css

17
Makefile Normal file
View File

@ -0,0 +1,17 @@
PACKAGES=unitJS
TARGETS=index.js game.js skin.css
.PHONY: mrproper
all: $(TARGETS)
%.js: js/
sjw -o $@ $(PACKAGES:%=-I %) -m Main.$(@:%.js=%) $^
skin.css: skin/
cat $^*.css > $@
rebuild: mrproper all
mrproper:
rm -f $(TARGETS)

39
game.html Normal file
View File

@ -0,0 +1,39 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>KoiKoi</title>
<script src="game.js"></script>
<link rel="stylesheet" href="skin.css" type="text/css"/>
</head>
<body>
<div id="game">
<div id="them">
<ul class="hand"></ul>
<ul class="kasu"></ul>
<ul class="tane"></ul>
<ul class="tan"></ul>
<ul class="hikari"></ul>
<span class="score"></span>
</div>
<div id="table">
<ul id="deck">
<li class="card init" id="rest"></li>
</ul>
<ul id="river"></ul>
<ul id="status"></ul>
</div>
<div id="you">
<ul class="hand"></ul>
<ul class="kasu"></ul>
<ul class="tane"></ul>
<ul class="tan"></ul>
<ul class="hikari"></ul>
<span class="score"></span>
</div>
</div>
<div id="dialog">
</div>
<p id="error"></p>
</body>
</html>

41
index.html Normal file
View File

@ -0,0 +1,41 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<title>KoiKoi</title>
<script src="index.js"></script>
<link rel="stylesheet" href="skin.css" type="text/css"/>
</head>
<body>
<div id="reception" class="on">
<h1>KoiKoi</h1>
<form id="login">
<input type="submit" name="submitButton" hidden disabled/>
<p id="join">
<label for="you"></label><input type="text" name="you"/>
<input type="submit" name="join" disabled/>
</p>
</form>
</div>
<div id="hall">
<form id="room">
<input type="submit" name="submitButton" hidden disabled/>
<p id="invite">
<label for="them"></label><input type="text" name="them"/>
<input type="submit" name="invite" disabled/>
</p>
<div class="listSelector" id="players">
<span class="message"></span>
<ul></ul>
</div>
</form>
<div class="listSelector" id="games">
<span class="message"></span>
<ul></ul>
</div>
</div>
<div id="dialog">
</div>
<p id="error"></p>
</body>
</html>

45
js/GUI/ConnectedForm.js Normal file
View File

@ -0,0 +1,45 @@
import Messaging;
var connectedForms = {};
Messaging.addEventListener('open', refreshForms);
Messaging.addEventListener('close', refreshForms);
return {
get: get
};
function ConnectedForm(root) {
var submits = root.querySelectorAll('[type=submit]');
var enabled = false
return {
enable: enable,
refresh: refresh,
root: root
}
function enable(setEnabled) {
enabled = setEnabled || undefined == setEnabled;
refresh();
}
function refresh() {
submits.forEach(function(button) {
button.disabled = !(Messaging.isOn() && enabled);
});
}
}
function get(formId) {
if(connectedForms[formId] == undefined) {
connectedForms[formId] = ConnectedForm(document.getElementById(formId));
}
return connectedForms[formId];
}
function refreshForms() {
for(var key in connectedForms) {
connectedForms[key].refresh();
}
}

24
js/GUI/ListSelector.js Normal file
View File

@ -0,0 +1,24 @@
import * as Dom from UnitJS.Dom;
function ListSelector(domId, lineOfElement) {
var root = document.getElementById(domId);
var message = root.getElementsByClassName('message')[0];
var list = root.getElementsByTagName('ul')[0];
return {
message: message,
refresh: refresh
};
function refresh(sortedElements) {
Dom.clear(list);
message.textContent = '';
sortedElements.forEach(function(element) {
list.appendChild(lineOfElement(element));
});
}
}
return {
make: ListSelector
};

49
js/GUI/Screen.js Normal file
View File

@ -0,0 +1,49 @@
import * as Dom from UnitJS.Dom;
import I18n;
var current = document.querySelector("body > div.on");
var errorBox = document.getElementById('error');
errorBox.addEventListener('click', function() {
errorBox.className = "";
});
return {
error: error,
dialog: dialog,
select: select
};
function select(name) {
current.className = "";
current = document.getElementById(name);
current.className = "on";
}
function closeAndRun(dialog, action) {
return function() {
dialog.className = '';
Dom.clear(dialog);
action();
};
}
function dialog(config) {
var layer = document.getElementById('dialog');
var dialog = Dom.make('div', {});
dialog.appendChild(Dom.make('p', {textContent: config.text}));
var answers = Dom.make('p', {class: 'answers'});
for(var i in config.answers) {
answers.appendChild(Dom.make('button', {
textContent: I18n.get(config.answers[i].label),
onClick: closeAndRun(layer, config.answers[i].action)
}));
}
dialog.appendChild(answers);
layer.appendChild(dialog);
layer.className = "on";
}
function error(message) {
errorBox.textContent = message;
errorBox.className = "on";
}

140
js/GUI/Screen/Hall.js Normal file
View File

@ -0,0 +1,140 @@
import * as Dom from UnitJS.Dom;
import I18n;
import * as ConnectedForm from GUI.ConnectedForm;
import * as ListSelector from GUI.ListSelector;
import Messaging;
import room as players from Room;
import Session;
import StatusHandler;
import Table;
var room = ConnectedForm.get('room');
var form = room.root;
var playersList = ListSelector.make('players', showPlayer);
var games = Table.make(game, 'date');
var gamesList = ListSelector.make('games', showGame);
var them = null;
return {
init: init
};
function init() {
initDOMEvents();
initMessageHandlers();
}
function initDOMEvents() {
form.addEventListener('submit', function(e) {
e.preventDefault();
Messaging.send({tag: "Invitation", to: them});
});
form.them.addEventListener("input", function() {refreshPlayers();});
}
function initMessageHandlers() {
Messaging.addEventListener(["Okaeri"], function(o) {
refresh();
});
Messaging.addEventListener(["Welcome"], function(o) {
refresh();
});
Messaging.addEventListener(["LogIn"], function(o) {
if(!Session.is(o.from)) {
refresh();
}
});
Messaging.addEventListener(["LogOut"], function(o) {
refresh();
});
Messaging.addEventListener(["Relay", "Invitation"], function(o) {
var from = players.get(o.from);
// invitations should come only from known players, in doubt say «no»
if(from != undefined && from.name) {
StatusHandler.set("🎴");
games.insert(o.from, from.name);
refreshGames();
} else {
Messaging.send({tag: "Answer", accept: false});
}
});
Messaging.addEventListener(["Relay", "Answer"], function(o) {
games.remove(o.from);
refreshGames();
/*
if(o.message.accept) {
modules.screen.select("game");
}
*/
});
Messaging.addEventListener(["Game"], function(o) {
});
}
function showPlayer(player) {
return Dom.make('li', {
textContent: player.name,
onClick: function() {form.them.value = player.name; refreshPlayers();},
class: 'player'
});
}
function game(key, vs) {
return {
key: key,
vs: vs,
date: Date.now()
};
}
function showGame(game) {
return Dom.make('li', {}, [
Dom.make('button', {
textContent: I18n.get('accept'),
onClick: function() {
Messaging.send({tag: "Answer", accept: true, to: game.key});
}
}),
Dom.make('span', {textContent: 'A game vs. ' + game.vs}),
]);
}
function refresh() {
refreshPlayers();
refreshGames();
}
function refreshPlayers() {
var name = form.them.value;
them = null;
var filtered = players.getAll(
function(player) {return player.name.match(name);}
);
playersList.refresh(filtered);
var exact = filtered.find(exactMatch(name));
if(exact != undefined) {
them = exact.key;
} else if(filtered.length == 1) {
them = filtered[0].key;
} else if(filtered.length == 0) {
playersList.message.textContent = I18n.get(
name.length > 0 ? "notFound" : "alone"
);
}
room.enable(them != undefined);
}
function exactMatch(name) {
return function(player) {
return player.name === name;
};
}
function refreshGames() {
var sortedGames = games.getAll();
gamesList.refresh(sortedGames);
if(sortedGames.length < 1) {
gamesList.message.textContent = I18n.get('noGames');
}
}

45
js/GUI/Screen/Login.js Normal file
View File

@ -0,0 +1,45 @@
import I18n;
import GUI.ConnectedForm;
import select from GUI.Screen;
import Messaging;
import Session;
import Save;
var login = GUI.ConnectedForm.get('login');
var form = login.root;
return {
init: init
};
function init() {
initDOM();
initMessageHandlers();
var name = Save.get('player.name');
if(name != undefined && name.length > 0) {
form.you.value = name;
login.enable();
}
}
function initDOM() {
form.getElementsByTagName('label')[0].textContent = I18n.get('pickName');
form.join.value = I18n.get('join');
form.addEventListener('submit', function(e) {
e.preventDefault();
Session.start(form.you.value);
});
form.you.addEventListener("input", validate);
}
function initMessageHandlers() {
Messaging.addEventListener(["LogIn"], function(o) {
if(Session.is(o.from)) {
select('hall');
}
});
}
function validate(e) {
login.enable(e.target.value != "");
}

32
js/I18n.js Normal file
View File

@ -0,0 +1,32 @@
import Translations;
var language = chooseLanguage();
return {
get: get
};
function chooseLanguage() {
var userPreference = navigator.language || navigator.userLanguage;
if(userPreference != undefined) {
if(Translations[userPreference] != undefined) {
return userPreference;
}
var lang = userPreference.replace(/-.*/, '');
for(var key in Translations) {
if(key.replace(/-.*/, '') == lang) {
return key;
}
}
}
if(Translations['en-US'] != undefined) {
return 'en-US';
}
for(var key in Translations) {
return key;
}
}
function get(textId) {
return Translations[language][textId] || ('TRANSLATE "'+textId+'" !!');
}

127
js/Messaging.js Normal file
View File

@ -0,0 +1,127 @@
import error as popError from GUI.Screen;
var wsLocation = window.location.origin.replace(/^http/, 'ws') + '/play/';
var ws;
var debug = getParameters().debug;
var doLog = debug != undefined && debug.match(/^(?:1|t(?:rue)?|v(?:rai)?)$/i);
var on = false;
var s = 1000; /* ms */
var keepAlivePeriod = 20;
var reconnectDelay = 1;
var routes = {callbacks: [], children: {}};
var wsHandlers = {
open: [function() {on = true; reconnectDelay = 1}, ping],
close: [function() {on = false;}, reconnect]
};
init();
return {
addEventListener: addEventListener,
isOn: isOn,
send: send
};
function get(obj, path, write) {
write = write || false;
if(path.length < 1) {
return obj;
} else {
if(obj.children[path[0]] == undefined && write) {
obj.children[path[0]] = {callbacks: [], children: {}};
}
if(obj.children[path[0]] != undefined) {
return get(obj.children[path[0]], path.slice(1), write);
} else {
return null;
}
}
}
function getParameters() {
var o = {};
window.location.search.substr(1).split('&').forEach(function(s) {
var t = s.split('=');
o[t[0]] = t[1];
});
return o;
}
function addEventListener(path, callback) {
if(Array.isArray(path)) {
var route = get(routes, path, true);
route.callbacks.push(callback);
} else {
if(wsHandlers[path] != undefined) {
wsHandlers[path].push(callback);
} else {
log('Unsupported websocket event "' + path + '"');
}
}
}
function messageListener(event) {
var o = JSON.parse(event.data);
var path = [];
var tmp = o;
while(tmp != undefined && tmp.tag != undefined) {
path.push(tmp.tag);
tmp = tmp.message;
}
var route = get(routes, path);
if(route != undefined && route.callbacks != undefined) {
route.callbacks.forEach(function(f) {f(o);});
} else {
console.log("No route found for " + event.data);
}
o.direction = 'client < server';
log(o);
};
function send(o) {
ws.send(JSON.stringify(o));
o.direction = 'client > server';
log(o);
}
function log(message) {
if(doLog) {
console.log(message);
}
}
function init() {
connect();
addEventListener(["Pong"], ping);
addEventListener(["Error"], function(o) {popError(o.error);});
}
function connect() {
ws = new WebSocket(window.location.origin.replace(/^http/, 'ws') + '/play/');
ws.addEventListener('message', messageListener);
ws.addEventListener('open', function(e) {
wsHandlers.open.forEach(function(handler) {handler(e);});
});
ws.addEventListener('close', function(e) {
wsHandlers.close.forEach(function(handler) {handler(e);});
});
}
function reconnect() {
setTimeout(connect, reconnectDelay * s);
if(reconnectDelay < 16) {
reconnectDelay *= 2;
}
}
function isOn() {
return on;
}
function ping() {
setTimeout(function() {
if(isOn()) {
send({tag: "Ping"});
}
}, keepAlivePeriod * s);
}

34
js/Room.js Normal file
View File

@ -0,0 +1,34 @@
import Messaging;
import Session;
import Table;
var room = Table.make(player, 'name');
initMessageHandlers();
return {
room: room
};
function player(key, name) {
return {
key: key,
name: name
};
}
function initMessageHandlers() {
Messaging.addEventListener(["Okaeri"], function(o) {
room.insertAll(o.room);
});
Messaging.addEventListener(["Welcome"], function(o) {
room.insertAll(o.room);
});
Messaging.addEventListener(["LogIn"], function(o) {
if(!Session.is(o.from)) {
room.insert(o.from, o.as);
}
});
Messaging.addEventListener(["LogOut"], function(o) {
room.remove(o.from);
});
}

46
js/Save.js Normal file
View File

@ -0,0 +1,46 @@
var save = JSON.parse(localStorage.getItem('save')) || {};
return {
get: get,
set: set
};
function move(coordinates) {
if(coordinates.path.length == 1) {
return coordinates;
} else {
var newFocus = coordinates.focus[coordinates.path[0]];
if (newFocus != undefined) {
var newCoordinates = {path: coordinates.path.slice(1), focus: newFocus};
return move(newCoordinates);
} else {
return coordinates;
}
}
}
function get(key) {
if(key != undefined) {
var outputCoordinates = move({path: key.split('.'), focus: save});
if(outputCoordinates.focus != undefined && outputCoordinates.path.length == 1) {
return outputCoordinates.focus[outputCoordinates.path[0]]
} else {
return null;
}
}
}
function set(key, value) {
if(key != undefined) {
var outputCoordinates = move({path: key.split('.'), focus: save});
while(outputCoordinates.path.length > 1) {
outputCoordinates.focus[outputCoordinates.path[0]] = {};
outputCoordinates.focus = outputCoordinates.focus[outputCoordinates.path[0]];
outputCoordinates.path = outputCoordinates.path.slice(1);
}
outputCoordinates.focus[outputCoordinates.path[0]] = value;
} else {
save = value;
}
localStorage.setItem('save', JSON.stringify(save));
}

49
js/Session.js Normal file
View File

@ -0,0 +1,49 @@
import Messaging;
import Save;
var key = null;
var playerKey = null;
var name = null;
var loggedIn = false;
Messaging.addEventListener(["Welcome"], function(o) {
playerKey = o.key;
Save.set('player.id', o.key);
});
Messaging.addEventListener(["LogIn"], function(o) {
if(is(o.from)) {
name = o.as;
loggedIn = true;
}
});
return {
is: is,
getKey: getKey,
isLoggedIn: isLoggedIn,
start: start
};
function is(somePlayerKey) {
return playerKey == somePlayerKey;
}
function getKey() {
return key;
}
function isLoggedIn() {
return loggedIn;
}
function start(name) {
var myID = Save.get('player.id');
if(myID != undefined) {
Messaging.send({tag: 'Tadaima', myID: myID, name: name});
playerKey = myID;
} else {
Messaging.send({tag: 'Hello', name: name});
}
Save.set('player.name', name);
}

17
js/StatusHandler.js Normal file
View File

@ -0,0 +1,17 @@
var baseTitle = document.title;
window.addEventListener('focus', reset);
return {
reset: reset,
set: set
};
function reset() {
document.title = baseTitle;
}
function set(newStatus) {
if(!document.hasFocus()) {
document.title = newStatus + " - " + baseTitle;
}
}

42
js/Table.js Normal file
View File

@ -0,0 +1,42 @@
import of from UnitJS.Fun;
import {compare, of, proj} from UnitJS.Fun;
function Table(itemMaker, sortCriterion) {
var items = {};
return {
get: get,
getAll: getAll,
insert: insert,
insertAll: insertAll,
remove: remove
};
function get(key) {
return items[key];
}
function getAll(criterion) {
return Object.keys(items)
.map(of(items))
.filter(criterion || function() {return true;})
.sort(compare(proj(sortCriterion)));
}
function insert(key, value) {
items[key] = itemMaker(key, value);
}
function insertAll(itemsByKey) {
for(var key in itemsByKey) {
insert(key, itemsByKey[key]);
}
}
function remove(key) {
delete items[key];
}
}
return {
make: Table
}

90
js/Translations.js Normal file
View File

@ -0,0 +1,90 @@
return {
'en-US': {
BushClover: "bush clover",
Cherry: "cherry",
Chrysanthemum: "chrysanthemum",
Iris: "iris",
Maple: "maple",
Paulownia: "paulownias",
Peony: "peony",
Pine: "pine",
Plum: "plum",
SusukiGrass: "susuki grass",
Willow: "willow",
Wisteria: "wisteria",
accept: "Accept",
alone: "No one to play with yet ! Wait a little",
decline: "Decline",
endRound: "End the round",
endGame: "Return to main menu",
join: "Join",
invite: "Invite",
invited: function(name) {
return name + " has invited you to a game";
},
koikoi: "KoiKoi !!",
leave: "Leave",
lost: "You lost the game",
monthFlower: function(flower) {
return "This month's flower is the " + flower;
},
noGames: "No games being played",
notFound: "No one goes by that name",
pickName: "Pick a name you like",
playing: function(name) {
return name + " is playing";
},
ok: "Ok",
startGameWith: "Start a game with",
theyScored: function(name) {
return name + " scored";
},
won: "You won !",
yourTurn: "Your turn",
youScored: "You scored ! Do you want to get your points and end the round or KoiKoi ?"
},
'fr-FR': {
BushClover: "lespédézas",
Cherry: "cerisiers",
Chrysanthemum: "chrysanthèmes",
Iris: "iris",
Maple: "érables",
Paulownia: "paulownias",
Peony: "pivoines",
Pine: "pins",
Plum: "prunus",
SusukiGrass: "herbes susukis",
Willow: "saules",
Wisteria: "glycines",
accept: "Accepter",
alone: "Personne pour jouer pour l'instant ! Attendez un peu",
decline: "Refuser",
endRound: "Finir la manche",
endGame: "Retourner au menu principal",
join: "Entrer",
invite: "Inviter",
invited: function(name) {
return name + " vous propose une partie";
},
koikoi: "KoiKoi !!",
leave: "Partir",
lost: "Vous avez perdu",
monthFlower: function(flower) {
return "C'est le mois des " + flower;
},
noGames: "Aucune partie en cours",
notFound: "Personne ne s'appelle comme ça",
pickName: "Choisissez votre nom",
playing: function(name) {
return "C'est à " + name;
},
ok: "Ok",
startGameWith: "Commencer une partie avec",
theyScored: function(name) {
return name + " a marqué";
},
won: "Vous avez gagné !",
yourTurn: "À vous",
youScored: "Vous avez marqué ! Voulez-vous empocher vos gains et terminer la manche ou faire KoiKoi ?"
}
}

BIN
skin/cards.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

201
skin/game.css Normal file
View File

@ -0,0 +1,201 @@
#game > div {
position: absolute;
left: 0;
right: 0;
}
#them {
top: 0;
bottom: 75%;
}
#table {
top: 25%;
bottom: 25%;
}
#you {
top: 75%;
bottom: 0;
}
#game .card {
background: url("/cards.jpg") no-repeat;
background-size: 400% 1300%;
display: inline-block;
border-radius: 0.5em;
border: 1px solid #555;
width: 4.5em;
height: 7em;
float: left;
margin: 0.5em;
background-position: 0% 100%; /* back of the card */
}
#game .card.value0 {
background-position-x: 0%;
}
#game .card.value1 {
background-position-x: 33.3%;
}
#game .card.value2 {
background-position-x: 66.7%;
}
#game .card.value3 {
background-position-x: 100%;
}
#game .card.month0 {
background-position-y: 0%;
}
#game .card.month1 {
background-position-y: 8.3%;
}
#game .card.month2 {
background-position-y: 16.7%;
}
#game .card.month3 {
background-position-y: 25%;
}
#game .card.month4 {
background-position-y: 33.3%;
}
#game .card.month5 {
background-position-y: 41.7%;
}
#game .card.month6 {
background-position-y: 50%;
}
#game .card.month7 {
background-position-y: 58.3%;
}
#game .card.month8 {
background-position-y: 66.7%;
}
#game .card.month9 {
background-position-y: 75%;
}
#game .card.month10 {
background-position-y: 83.3%;
}
#game .card.month11 {
background-position-y: 91.7%;
}
#game .card.slot {
background: none;
border: 1px solid transparent;
}
#game .card.moving {
position: relative;
transition-property: left, top;
transition-duration: 1s;
}
#game #rest {
margin: 0;
}
#rest.init, #rest.turn0 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 6px 9px 0 0 #555;
}
#rest.turn2 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 5.5px 8.3px 0 0 #555;
}
#rest.turn4 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 5px 7.5px 0 0 #555;
}
#rest.turn6 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555, 4.5px 6.8px 0 0 #555;
}
#rest.turn8 {
box-shadow: 2px 3px 0 0 #555, 4px 6px 0 0 #555;
}
#rest.turn10 {
box-shadow: 2px 3px 0 0 #555, 3.5px 5.3px 0 0 #555;
}
#rest.turn12 {
box-shadow: 2px 3px 0 0 #555, 3px 4.5px 0 0 #555;
}
#rest.turn14 {
box-shadow: 2px 3px 0 0 #555, 2.5px 3.8px 0 0 #555;
}
#rest.turn16 {
box-shadow: 2px 3px 0 0 #555;
}
#game #turned {
margin: -0.1em 0 0 -4.75em;
}
#river li.card.candidate, #you .hand.yourTurn li.card {
cursor: pointer;
}
#river li.card.candidate {
box-shadow: 0 0 0.5em 0.1em #fc0;
}
#you .hand li.card.selected {
margin-top: -1em;
}
#game ul {
display: inline-block;
margin: 0 0 0 3.5em;
padding: 0;
float: right;
position: relative;
top: 50%;
transform: translateY(-50%);
}
#game ul#deck {
float: left;
}
#game ul#river {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
margin-left: 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
#them .card, #you .card {
margin-left: -3.5em;
}
#game .hand {
margin-left: 0;
float: left;
}
#game .hand .card {
margin: 0.5em 0.1em;
}

25
skin/hall.css Normal file
View File

@ -0,0 +1,25 @@
.listSelector {
min-height: 4em;
border: 1px solid #ccc;
}
.listSelector .message {
display: block;
text-align: center;
margin: 1em;
color: #555;
}
.listSelector .message:empty {
display: none;
}
.listSelector ul {
list-style: none;
margin: 0;
padding-left: 0;
}
.listSelector .player {
cursor: pointer;
}

54
skin/screen.css Normal file
View File

@ -0,0 +1,54 @@
body > div {
display: none;
}
body > div.on {
display: block;
}
#dialog {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.1);
}
#dialog > div {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: #fff;
padding: 0 1em;
border: 1px solid #aaa;
border-radius: 0.5em;
}
#dialog p.answers {
text-align: center;
}
#dialog button {
display: inline-block;
}
#error {
position: absolute;
z-index: 1;
top: 1em;
right: 1em;
max-width: 20em;
border: 1px solid #e0afac;
padding: 1em;
border-radius: 0.5em;
background: bisque;
cursor: pointer;
margin: 0;
display: none;
}
#error.on {
display: block;
}