Compare commits
11 commits
b83cede3ec
...
a7ded5fae3
Author | SHA1 | Date | |
---|---|---|---|
a7ded5fae3 | |||
4869a31ec2 | |||
253ccbadc2 | |||
b9df147db5 | |||
184c876fb9 | |||
988dd1050b | |||
735ca360c8 | |||
265d774b4f | |||
3fc8104bdd | |||
c21ab3b90f | |||
3e28d4b6e1 |
11 changed files with 323 additions and 57 deletions
|
@ -35,9 +35,9 @@ Carbon is currently _technically_ usable as a chat app, but is very
|
||||||
early in development. These important features still need to be
|
early in development. These important features still need to be
|
||||||
implemented:
|
implemented:
|
||||||
|
|
||||||
- Login GUI
|
|
||||||
- Unreads
|
- Unreads
|
||||||
- Chat history
|
- Chat history
|
||||||
|
- Typing indicators
|
||||||
- Formatting
|
- Formatting
|
||||||
- Emojis
|
- Emojis
|
||||||
- Reactions
|
- Reactions
|
||||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "cosc212-assignment-1",
|
"name": "carbon",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
|
|
67
spec.js
67
spec.js
|
@ -2,121 +2,136 @@ module.exports = [
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/fonts/whitney-500.woff",
|
source: "/assets/fonts/whitney-500.woff",
|
||||||
target: "/static/whitney-500.woff"
|
target: "/static/whitney-500.woff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/fonts/whitney-400.woff",
|
source: "/assets/fonts/whitney-400.woff",
|
||||||
target: "/static/whitney-400.woff"
|
target: "/static/whitney-400.woff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "js",
|
||||||
|
source: "/js/main.js",
|
||||||
|
target: "/static/main.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/basic.js",
|
source: "/js/basic.js",
|
||||||
target: "/static/basic.js"
|
target: "/static/basic.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/groups.js",
|
source: "/js/groups.js",
|
||||||
target: "/static/groups.js"
|
target: "/static/groups.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/chat-input.js",
|
source: "/js/chat-input.js",
|
||||||
target: "/static/chat-input.js"
|
target: "/static/chat-input.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/room-picker.js",
|
source: "/js/room-picker.js",
|
||||||
target: "/static/room-picker.js"
|
target: "/static/room-picker.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/store/store.js",
|
source: "/js/store/store.js",
|
||||||
target: "/static/store/store.js"
|
target: "/static/store/store.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/store/Subscribable.js",
|
source: "/js/store/Subscribable.js",
|
||||||
target: "/static/store/Subscribable.js"
|
target: "/static/store/Subscribable.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/store/SubscribeValue.js",
|
source: "/js/store/SubscribeValue.js",
|
||||||
target: "/static/store/SubscribeValue.js"
|
target: "/static/store/SubscribeValue.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/store/SubscribeMapList.js",
|
source: "/js/store/SubscribeMapList.js",
|
||||||
target: "/static/store/SubscribeMapList.js"
|
target: "/static/store/SubscribeMapList.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/store/SubscribeSet.js",
|
source: "/js/store/SubscribeSet.js",
|
||||||
target: "/static/store/SubscribeSet.js"
|
target: "/static/store/SubscribeSet.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/sync/sync.js",
|
source: "/js/sync/sync.js",
|
||||||
target: "/static/sync/sync.js"
|
target: "/static/sync/sync.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/lsm.js",
|
source: "/js/lsm.js",
|
||||||
target: "/static/lsm.js"
|
target: "/static/lsm.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/Timeline.js",
|
source: "/js/Timeline.js",
|
||||||
target: "/static/Timeline.js"
|
target: "/static/Timeline.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/Anchor.js",
|
source: "/js/Anchor.js",
|
||||||
target: "/static/Anchor.js"
|
target: "/static/Anchor.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/chat.js",
|
source: "/js/chat.js",
|
||||||
target: "/static/chat.js"
|
target: "/static/chat.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "js",
|
||||||
source: "/js/functions.js",
|
source: "/js/functions.js",
|
||||||
target: "/static/functions.js"
|
target: "/static/functions.js",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "js",
|
||||||
|
source: "/js/login.js",
|
||||||
|
target: "/static/login.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/fonts/whitney-500.woff",
|
source: "/assets/fonts/whitney-500.woff",
|
||||||
target: "/static/whitney-500.woff"
|
target: "/static/whitney-500.woff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/icons/directs.svg",
|
source: "/assets/icons/directs.svg",
|
||||||
target: "/static/directs.svg"
|
target: "/static/directs.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/icons/channels.svg",
|
source: "/assets/icons/channels.svg",
|
||||||
target: "/static/channels.svg"
|
target: "/static/channels.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/icons/join-event.svg",
|
source: "/assets/icons/join-event.svg",
|
||||||
target: "/static/join-event.svg"
|
target: "/static/join-event.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "sass",
|
type: "sass",
|
||||||
source: "/sass/main.sass",
|
source: "/sass/main.sass",
|
||||||
target: "/static/main.css"
|
target: "/static/main.css",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "sass",
|
||||||
|
source: "/sass/login.sass",
|
||||||
|
target: "/static/login.css",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "pug",
|
type: "pug",
|
||||||
source: "/home.pug",
|
source: "/home.pug",
|
||||||
target: "/index.html"
|
target: "/index.html",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "pug",
|
type: "pug",
|
||||||
source: "/login.pug",
|
source: "/login.pug",
|
||||||
target: "/login.html"
|
target: "/login/index.html",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
|
@ -33,18 +33,14 @@ doctype html
|
||||||
html
|
html
|
||||||
head
|
head
|
||||||
meta(charset="utf-8")
|
meta(charset="utf-8")
|
||||||
|
title Carbon
|
||||||
// var static = !{JSON.stringify([...static.entries()].reduce((a, c) => (a[c[0]] = getRelative(c[1]), a), {}))}
|
// var static = !{JSON.stringify([...static.entries()].reduce((a, c) => (a[c[0]] = getRelative(c[1]), a), {}))}
|
||||||
script
|
script
|
||||||
| var staticFiles = new Map(
|
| var staticFiles = new Map(
|
||||||
!= JSON.stringify([...static.keys()].map(k => [k, getStatic(k)]))
|
!= JSON.stringify([...static.keys()].map(k => [k, getStatic(k)]))
|
||||||
| )
|
| )
|
||||||
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
|
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
|
||||||
script(type="module" src=getStatic("/js/groups.js"))
|
script(type="module" src=getStatic("/js/main.js"))
|
||||||
script(type="module" src=getStatic("/js/chat-input.js"))
|
|
||||||
script(type="module" src=getStatic("/js/room-picker.js"))
|
|
||||||
script(type="module" src=getStatic("/js/sync/sync.js"))
|
|
||||||
script(type="module" src=getStatic("/js/chat.js"))
|
|
||||||
title Carbon
|
|
||||||
body
|
body
|
||||||
main.main
|
main.main
|
||||||
.c-groups
|
.c-groups
|
||||||
|
|
|
@ -81,6 +81,8 @@ class Event extends ElemJS {
|
||||||
} else {
|
} else {
|
||||||
this.child(ejs("i").text("left the room"))
|
this.child(ejs("i").text("left the room"))
|
||||||
}
|
}
|
||||||
|
} else if (this.data.type === "m.room.encrypted") {
|
||||||
|
this.child(ejs("i").text("Carbon does not yet support encrypted messages."))
|
||||||
} else {
|
} else {
|
||||||
this.child(ejs("i").text(`Unsupported event type ${this.data.type}`))
|
this.child(ejs("i").text(`Unsupported event type ${this.data.type}`))
|
||||||
}
|
}
|
||||||
|
|
148
src/js/login.js
Normal file
148
src/js/login.js
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import {q, ElemJS, ejs} from $to_relative "/js/basic.js"
|
||||||
|
|
||||||
|
const password = q("#password")
|
||||||
|
const homeserver = q("#homeserver")
|
||||||
|
|
||||||
|
class Username extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super(q("#username"))
|
||||||
|
|
||||||
|
this.on("change", this.updateServer.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid() {
|
||||||
|
return !!this.element.value.match(/^@?[a-z0-9._=\/-]+(?::[a-zA-Z0-9.:\[\]-]+)?$/)
|
||||||
|
}
|
||||||
|
|
||||||
|
getUsername() {
|
||||||
|
return this.element.value.match(/^@?([a-z0-9._=\/-]+)/)[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
getServer() {
|
||||||
|
const server = this.element.value.match(/^@?[a-z0-9._=\?-]+:([a-zA-Z0-9.:\[\]-]+)$/)
|
||||||
|
if (server && server[1]) return server[1]
|
||||||
|
else return null
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServer() {
|
||||||
|
if (!this.isValid()) return
|
||||||
|
if (this.getServer()) homeserver.value = this.getServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = new Username()
|
||||||
|
|
||||||
|
class Feedback extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super(q("#feedback"))
|
||||||
|
this.loading = false
|
||||||
|
this.loadingIcon = ejs("span").class("loading-icon")
|
||||||
|
this.messageSpan = ejs("span")
|
||||||
|
this.child(this.messageSpan)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(state) {
|
||||||
|
if (this.loading && !state) {
|
||||||
|
this.loadingIcon.remove()
|
||||||
|
} else if (!this.loading && state) {
|
||||||
|
this.childAt(0, this.loadingIcon)
|
||||||
|
}
|
||||||
|
this.loading = state
|
||||||
|
}
|
||||||
|
|
||||||
|
message(content) {
|
||||||
|
if (content) {
|
||||||
|
this.class("form-feedback")
|
||||||
|
} else {
|
||||||
|
this.removeClass("form-feedback")
|
||||||
|
}
|
||||||
|
this.messageSpan.text(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedback = new Feedback()
|
||||||
|
|
||||||
|
class Form extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super(q("#form"))
|
||||||
|
|
||||||
|
this.processing = false
|
||||||
|
|
||||||
|
this.on("submit", this.submit.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit() {
|
||||||
|
if (this.processing) return
|
||||||
|
this.processing = true
|
||||||
|
if (!username.isValid()) return this.cancel("Username is not valid.")
|
||||||
|
|
||||||
|
// Resolve homeserver address
|
||||||
|
let currentAddress = homeserver.value
|
||||||
|
let ok = false
|
||||||
|
while (!ok) {
|
||||||
|
if (!currentAddress.match(/^https?:\/\//)) currentAddress = "https://" + currentAddress
|
||||||
|
currentAddress = currentAddress.replace(/\/*$/, "")
|
||||||
|
this.status(`Looking up homeserver... trying ${currentAddress}`)
|
||||||
|
try {
|
||||||
|
// check if we found the actual matrix server
|
||||||
|
try {
|
||||||
|
const versions = await fetch(`${currentAddress}/_matrix/client/versions`).then(res => res.json())
|
||||||
|
if (Array.isArray(versions.versions)) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
// find the next matrix server in the chain
|
||||||
|
const root = await fetch(`${currentAddress}/.well-known/matrix/client`).then(res => res.json())
|
||||||
|
let nextAddress = root["m.homeserver"].base_url
|
||||||
|
nextAddress = nextAddress.replace(/\/*$/, "")
|
||||||
|
if (currentAddress === nextAddress) {
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
currentAddress = nextAddress
|
||||||
|
} catch (e) {
|
||||||
|
return this.cancel(`Failed to look up server ${currentAddress}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request access token
|
||||||
|
this.status("Logging in...")
|
||||||
|
const root = await fetch(`${currentAddress}/_matrix/client/r0/login`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: "m.login.password",
|
||||||
|
user: username.getUsername(),
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
}).then(res => res.json())
|
||||||
|
|
||||||
|
if (!root.access_token) {
|
||||||
|
if (root.error) {
|
||||||
|
this.cancel(`Server said: ${root.error}`)
|
||||||
|
} else {
|
||||||
|
this.cancel("Login mysteriously failed.")
|
||||||
|
console.error(root)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem("mx_user_id", root.user_id)
|
||||||
|
localStorage.setItem("domain", currentAddress)
|
||||||
|
localStorage.setItem("access_token", root.access_token)
|
||||||
|
|
||||||
|
location.assign("./")
|
||||||
|
}
|
||||||
|
|
||||||
|
status(message) {
|
||||||
|
feedback.setLoading(true)
|
||||||
|
feedback.message(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(message) {
|
||||||
|
this.processing = false
|
||||||
|
feedback.setLoading(false)
|
||||||
|
feedback.message(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new Form()
|
9
src/js/main.js
Normal file
9
src/js/main.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import $to_relative "/js/groups.js"
|
||||||
|
import $to_relative "/js/chat-input.js"
|
||||||
|
import $to_relative "/js/room-picker.js"
|
||||||
|
import $to_relative "/js/sync/sync.js"
|
||||||
|
import $to_relative "/js/chat.js"
|
||||||
|
|
||||||
|
if (!localStorage.getItem("access_token")) {
|
||||||
|
location.assign("./login")
|
||||||
|
}
|
|
@ -108,11 +108,13 @@ class Room extends ElemJS {
|
||||||
getIcon() {
|
getIcon() {
|
||||||
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
|
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
return resolveMxc(avatar.content.url || avatar.content.avatar_url, 32, "crop")
|
const url = avatar.content.url || avatar.content.avatar_url
|
||||||
} else {
|
if (url) {
|
||||||
return null
|
return resolveMxc(url, 32, "crop")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
isDirect() {
|
isDirect() {
|
||||||
return store.directs.has(this.id)
|
return store.directs.has(this.id)
|
||||||
|
|
|
@ -121,4 +121,6 @@ function syncLoop() {
|
||||||
|
|
||||||
store.activeGroup.set(store.groups.get("directs").value())
|
store.activeGroup.set(store.groups.get("directs").value())
|
||||||
|
|
||||||
|
if (lsm.get("access_token")) {
|
||||||
syncLoop()
|
syncLoop()
|
||||||
|
}
|
||||||
|
|
|
@ -2,20 +2,31 @@ doctype html
|
||||||
html
|
html
|
||||||
head
|
head
|
||||||
meta(charset="utf-8")
|
meta(charset="utf-8")
|
||||||
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
|
|
||||||
title Carbon
|
title Carbon
|
||||||
|
meta(name="viewport" content="width=device-width, initial-scale=1")
|
||||||
|
link(rel="stylesheet" type="text/css" href=getStatic("/sass/login.sass"))
|
||||||
|
script(type="module" src=getStatic("/js/login.js"))
|
||||||
|
|
||||||
body
|
body
|
||||||
main.main
|
main.main
|
||||||
form
|
.center-login-container
|
||||||
div
|
h1 Welcome to Carbon!
|
||||||
label(for="login") Username
|
form.login-form(method="post" onsubmit="return false")#form
|
||||||
input(type="text" name="login" autocomplete="username" placeholder="example:matrix.org" required)#login
|
.data-input
|
||||||
div
|
.form-input-container
|
||||||
label(for="password") Password
|
label(for="username") Username
|
||||||
input(type="text" name="password" autocomplete="current-password" required)#password
|
input(type="text" name="username" autocomplete="username" placeholder="@username:server.tld" pattern="^@?[a-z0-9._=/-]+(?::[a-zA-Z0-9.:\\[\\]-]+)?$" required)#username
|
||||||
div
|
|
||||||
|
|
||||||
|
.form-input-container
|
||||||
|
label(for="password") Password
|
||||||
|
input(name="password" autocomplete="current-password" type="password" required)#password
|
||||||
|
|
||||||
|
.form-input-container
|
||||||
label(for="homeserver") Homeserver
|
label(for="homeserver") Homeserver
|
||||||
input(type="text" name="homeserver" value="matrix.org" required)#homeserver
|
input(type="text" name="homeserver" value="matrix.org" placeholder="matrix.org" required)#homeserver
|
||||||
div
|
|
||||||
input(type="submit" value="Login")
|
#feedback
|
||||||
|
|
||||||
|
.form-input-container
|
||||||
|
input(type="submit" value="Log in")#submit
|
||||||
|
|
||||||
|
|
81
src/sass/login.sass
Normal file
81
src/sass/login.sass
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
@use "./base"
|
||||||
|
@use "./colors.sass" as c
|
||||||
|
|
||||||
|
.main
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
.center-login-container
|
||||||
|
display: flex
|
||||||
|
flex-flow: column
|
||||||
|
justify-content: center
|
||||||
|
align-items: center
|
||||||
|
width: min(100vw, 450px)
|
||||||
|
padding: max(1rem,3vw) 2rem
|
||||||
|
margin: 8px
|
||||||
|
box-shadow: 0px 2px 10px c.$darkest
|
||||||
|
background-color: c.$darker
|
||||||
|
border-radius: 5px
|
||||||
|
|
||||||
|
.login-form
|
||||||
|
align-items: center
|
||||||
|
flex: 1 1 auto
|
||||||
|
width: 100%
|
||||||
|
display: flex
|
||||||
|
justify-content: space-around
|
||||||
|
flex-flow: column
|
||||||
|
|
||||||
|
.data-input
|
||||||
|
width: 100%
|
||||||
|
|
||||||
|
.form-input-container
|
||||||
|
width: 100%
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
margin: 1em 0
|
||||||
|
|
||||||
|
.form-feedback
|
||||||
|
width: 100%
|
||||||
|
margin: -0.5em 0 -0.8em
|
||||||
|
|
||||||
|
@keyframes spin
|
||||||
|
0%
|
||||||
|
transform: rotate(0deg)
|
||||||
|
100%
|
||||||
|
transform: rotate(180deg)
|
||||||
|
|
||||||
|
.loading-icon
|
||||||
|
display: inline-block
|
||||||
|
background-color: #ccc
|
||||||
|
width: 12px
|
||||||
|
height: 12px
|
||||||
|
margin-right: 6px
|
||||||
|
animation: spin 0.7s infinite
|
||||||
|
|
||||||
|
input, button
|
||||||
|
font-family: inherit
|
||||||
|
font-size: 17px
|
||||||
|
background-color: c.$mild
|
||||||
|
color: #eee
|
||||||
|
width: 100%
|
||||||
|
border-radius: 5px
|
||||||
|
box-sizing: border-box
|
||||||
|
transition: background-color 0.15s ease-out, border-color 0.15s ease-out
|
||||||
|
padding: 4px 9px
|
||||||
|
border: 0px
|
||||||
|
|
||||||
|
input[type="text"],input[type="password"]
|
||||||
|
border: 3px solid transparent
|
||||||
|
margin: 0.4em 0px
|
||||||
|
|
||||||
|
&:hover, &:focus
|
||||||
|
border-color: c.$milder
|
||||||
|
|
||||||
|
button, input[type="submit"]
|
||||||
|
padding: 7px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background-color: c.$milder
|
||||||
|
|
||||||
|
label
|
||||||
|
font-size: 18px
|
Loading…
Reference in a new issue