From e3e504310aae16bd24b5e00e23d0b307aace529f Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Mon, 22 Jul 2019 13:53:37 -0400 Subject: Add Feature: Comments can be richer (can support some safe HTML tags) --- .meteor/packages | 1 + .meteor/versions | 2 + client/components/main/editor.js | 132 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/.meteor/packages b/.meteor/packages index ccbff63d..875b1f98 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -94,3 +94,4 @@ lamhieu:unblock meteorhacks:aggregate@1.3.0 wekan-markdown konecty:mongo-counter +summernote:summernote diff --git a/.meteor/versions b/.meteor/versions index 13400796..2ca1d706 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -168,6 +168,7 @@ standard-minifier-css@1.5.3 standard-minifier-js@2.4.1 staringatlights:fast-render@3.2.0 staringatlights:inject-data@2.3.0 +summernote:summernote@0.8.1 tap:i18n@1.8.2 templates:tabs@2.3.0 templating@1.3.2 @@ -175,6 +176,7 @@ templating-compiler@1.3.3 templating-runtime@1.3.2 templating-tools@1.1.2 tracker@1.2.0 +twbs:bootstrap@3.3.6 ui@1.0.13 underscore@1.0.10 url@1.2.0 diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 2824723d..400043f2 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,9 +1,7 @@ Template.editor.onRendered(() => { - const $textarea = this.$('textarea'); - - autosize($textarea); - - $textarea.escapeableTextComplete([ + const textareaSelector = 'textarea'; + const disableRicherEditor = Meteor.settings.public.NO_RICHER_EDITOR; + const mentions = [ // User mentions { match: /\B@([\w.]*)$/, @@ -27,7 +25,129 @@ Template.editor.onRendered(() => { }, index: 1, }, - ]); + ]; + if (!disableRicherEditor) { + const isSmall = Utils.isMiniScreen(); + const toolbar = isSmall + ? [ + ['font', ['bold', 'underline']], + ['fontsize', ['fontsize']], + ['color', ['color']], + ['table', ['table']], + ['view', ['fullscreen']], + ] + : [ + ['style', ['style']], + ['font', ['bold', 'underline', 'clear']], + ['fontsize', ['fontsize']], + ['fontname', ['fontname']], + ['color', ['color']], + ['para', ['ul', 'ol', 'paragraph']], + ['table', ['table']], + //['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled + ['insert', ['link', 'picture']], + ['view', ['fullscreen', 'help']], + ]; + const cleanPastedHTML = function(input) { + const badTags = [ + 'style', + 'script', + 'applet', + 'embed', + 'noframes', + 'noscript', + 'meta', + 'link', + 'button', + 'form', + ].join('|'); + const badPatterns = new RegExp( + `(?:${[ + `<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`, + `<(${badTags})[^>]*?\\/>`, + ].join('|')})`, + 'gi', + ); + let output = input; + // remove bad Tags + output = output.replace(badPatterns, ''); + // remove attributes ' style="..."' + const badAttributes = new RegExp( + `(?:${[ + 'on\\S+=([\'"]?).*?\\1', + 'href=([\'"]?)javascript:.*?\\2', + 'style=([\'"]?).*?\\3', + 'target=\\S+', + ].join('|')})`, + 'gi', + ); + output = output.replace(badAttributes, ''); + output = output.replace(/( Date: Mon, 22 Jul 2019 23:33:44 -0400 Subject: Add Feature: Comments can be richer (can support some safe HTML tags) --- client/components/activities/comments.js | 2 +- client/components/cards/cardDetails.styl | 2 +- client/components/main/editor.js | 154 +++++++++++++++++++------------ 3 files changed, 96 insertions(+), 62 deletions(-) diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js index 3fc5770c..8289b628 100644 --- a/client/components/activities/comments.js +++ b/client/components/activities/comments.js @@ -54,7 +54,7 @@ BlazeComponent.extendComponent({ // XXX This should be a static method of the `commentForm` component function resetCommentInput(input) { - input.val(''); + input.val('').trigger('input'); // without manually trigger, input event won't be fired input.blur(); commentFormIsOpen.set(false); } diff --git a/client/components/cards/cardDetails.styl b/client/components/cards/cardDetails.styl index da0fe9f8..4bba2d4d 100644 --- a/client/components/cards/cardDetails.styl +++ b/client/components/cards/cardDetails.styl @@ -126,7 +126,7 @@ input[type="submit"].attachment-add-link-submit @media screen and (max-width: 800px) .card-details - width: calc(100% - 40px) + width: calc(100% - 1px) padding: 0px 20px 0px 20px margin: 0px transition: none diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 400043f2..9c1ad7a8 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -26,15 +26,20 @@ Template.editor.onRendered(() => { index: 1, }, ]; + const enableTextarea = function() { + const $textarea = this.$(textareaSelector); + autosize($textarea); + $textarea.escapeableTextComplete(mentions); + }; if (!disableRicherEditor) { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ + ['view', ['fullscreen']], + ['table', ['table']], ['font', ['bold', 'underline']], - ['fontsize', ['fontsize']], + //['fontsize', ['fontsize']], ['color', ['color']], - ['table', ['table']], - ['view', ['fullscreen']], ] : [ ['style', ['style']], @@ -45,7 +50,7 @@ Template.editor.onRendered(() => { ['para', ['ul', 'ol', 'paragraph']], ['table', ['table']], //['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled - ['insert', ['link', 'picture']], + //['insert', ['link', 'picture']], // modal popup has issue somehow :( ['view', ['fullscreen', 'help']], ]; const cleanPastedHTML = function(input) { @@ -90,63 +95,92 @@ Template.editor.onRendered(() => { `.js-new-comment-form ${editor}`, `.js-edit-comment ${editor}`, ].join(','); // only new comment and edit comment - $(selectors).summernote({ - callbacks: { - onInit(object) { - const jEditor = object && object.editor; - const toolbar = object && object.toolbar; - if (jEditor !== undefined) { - jEditor.find('.note-editable').escapeableTextComplete(mentions); - } - if (toolbar !== undefined) { - const fBtn = toolbar.find('.btn-fullscreen'); - fBtn.on('click', function() { - const $this = $(this), - isActive = $this.hasClass('active'); - $('.minicards').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually - }); - } - }, - onPaste() { - // clear up unwanted tag info when user pasted in text - const thisNote = $(this); - const updatePastedText = function(someNote) { - const original = someNote.summernote('code'); - const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML. - someNote.summernote('code', ''); //clear original - someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code. - }; - setTimeout(function() { - //this kinda sucks, but if you don't do a setTimeout, - //the function is called before the text is really pasted. - updatePastedText(thisNote); - }, 10); - }, - }, - dialogsInBody: true, - disableDragAndDrop: true, - toolbar, - popover: { - image: [ - [ - 'image', - ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone'], - ], - ['float', ['floatLeft', 'floatRight', 'floatNone']], - ['remove', ['removeMedia']], - ], - table: [ - ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']], - ['delete', ['deleteRow', 'deleteCol', 'deleteTable']], - ], - air: [['color', ['color']], ['font', ['bold', 'underline', 'clear']]], - }, - height: 200, - }); + const inputs = $(selectors); + if (inputs.length === 0) { + // only enable richereditor to new comment or edit comment no others + enableTextarea(); + } else { + const placeholder = inputs.attr('placeholder') || ''; + const mSummernotes = []; + const getSummernote = function(input) { + const idx = inputs.index(input); + if (idx > -1) { + return mSummernotes[idx]; + } + return undefined; + }; + inputs.each(function(idx, input) { + mSummernotes[idx] = $(input).summernote({ + placeholder, + callbacks: { + onInit(object) { + const originalInput = this; + $(originalInput).on('input', function() { + // when comment is submitted, the original textarea will be set to '', so shall we + if (!this.value) { + const sn = getSummernote(this); + sn && sn.summernote('reset'); + object && object.editingArea.find('.note-placeholder').show(); + } + }); + const jEditor = object && object.editable; + const toolbar = object && object.toolbar; + if (jEditor !== undefined) { + jEditor.escapeableTextComplete(mentions); + } + if (toolbar !== undefined) { + const fBtn = toolbar.find('.btn-fullscreen'); + fBtn.on('click', function() { + const $this = $(this), + isActive = $this.hasClass('active'); + $('.minicards').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually + }); + } + }, + onPaste() { + // clear up unwanted tag info when user pasted in text + const thisNote = this; + const updatePastedText = function(object) { + const someNote = getSummernote(object); + const original = someNote.summernote('code'); + const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML. + someNote.summernote('reset'); //clear original + someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code. + }; + setTimeout(function() { + //this kinda sucks, but if you don't do a setTimeout, + //the function is called before the text is really pasted. + updatePastedText(thisNote); + }, 10); + }, + }, + dialogsInBody: true, + disableDragAndDrop: true, + toolbar, + popover: { + image: [ + [ + 'image', + ['resizeFull', 'resizeHalf', 'resizeQuarter', 'resizeNone'], + ], + ['float', ['floatLeft', 'floatRight', 'floatNone']], + ['remove', ['removeMedia']], + ], + table: [ + ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']], + ['delete', ['deleteRow', 'deleteCol', 'deleteTable']], + ], + air: [ + ['color', ['color']], + ['font', ['bold', 'underline', 'clear']], + ], + }, + height: 200, + }); + }); + } } else { - const $textarea = this.$(textareaSelector); - autosize($textarea); - $textarea.escapeableTextComplete(mentions); + enableTextarea(); } }); -- cgit v1.2.3-1-g7c22 From 2eae236c5a732959a5a51fbd0eebb0d50682ebe2 Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Tue, 23 Jul 2019 14:06:41 -0400 Subject: Add Feature: User can have richer formatted text into the comments (bug fixed) --- .meteor/packages | 2 +- .meteor/versions | 2 +- client/components/main/layouts.styl | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.meteor/packages b/.meteor/packages index f777ea66..63f1f7eb 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -94,5 +94,5 @@ lamhieu:unblock meteorhacks:aggregate@1.3.0 wekan-markdown konecty:mongo-counter -summernote:summernote percolate:synced-cron +easylogic:summernote diff --git a/.meteor/versions b/.meteor/versions index 0d9f93dd..20f1d924 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -55,6 +55,7 @@ ddp-server@2.3.0 deps@1.0.12 diff-sequence@1.1.1 dynamic-import@0.5.1 +easylogic:summernote@0.8.8 ecmascript@0.12.7 ecmascript-runtime@0.7.0 ecmascript-runtime-client@0.8.0 @@ -169,7 +170,6 @@ standard-minifier-css@1.5.3 standard-minifier-js@2.4.1 staringatlights:fast-render@3.2.0 staringatlights:inject-data@2.3.0 -summernote:summernote@0.8.1 tap:i18n@1.8.2 templates:tabs@2.3.0 templating@1.3.2 diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl index 46ee720c..cc8be6af 100644 --- a/client/components/main/layouts.styl +++ b/client/components/main/layouts.styl @@ -2,6 +2,10 @@ global-reset() +* + -webkit-box-sizing: unset + box-sizing: unset + html, body, input, select, textarea, button font: 14px Roboto, "Helvetica Neue", Arial, Helvetica, sans-serif line-height: 18px -- cgit v1.2.3-1-g7c22 From a5c1395c46a2682f4e9c2cb1f2b36dd6aa930151 Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Wed, 24 Jul 2019 11:01:30 -0400 Subject: Add Feature: User can have richer formatted text into the comments (admin-panel fixed) --- client/components/main/editor.js | 4 ++-- client/components/main/layouts.styl | 3 +++ client/components/settings/settingBody.jade | 36 ++++++++++++++--------------- client/components/settings/settingBody.styl | 4 ++-- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 9c1ad7a8..0a6db12c 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,6 +1,6 @@ Template.editor.onRendered(() => { const textareaSelector = 'textarea'; - const disableRicherEditor = Meteor.settings.public.NO_RICHER_EDITOR; + const enableRicherEditor = Meteor.settings.public.RICHER_EDITOR || true; const mentions = [ // User mentions { @@ -31,7 +31,7 @@ Template.editor.onRendered(() => { autosize($textarea); $textarea.escapeableTextComplete(mentions); }; - if (!disableRicherEditor) { + if (enableRicherEditor) { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl index cc8be6af..dafff251 100644 --- a/client/components/main/layouts.styl +++ b/client/components/main/layouts.styl @@ -6,6 +6,9 @@ global-reset() -webkit-box-sizing: unset box-sizing: unset +.note-popover .popover-content .note-color-palette div .note-color-btn, .panel-heading.note-toolbar .note-color-palette div .note-color-btn + background: none + html, body, input, select, textarea, button font: 14px Roboto, "Helvetica Neue", Arial, Helvetica, sans-serif line-height: 18px diff --git a/client/components/settings/settingBody.jade b/client/components/settings/settingBody.jade index 89911e09..43836b2b 100644 --- a/client/components/settings/settingBody.jade +++ b/client/components/settings/settingBody.jade @@ -44,7 +44,7 @@ template(name="general") ul li .title {{_ 'invite-people'}} - textarea#email-to-invite.form-control(rows='5', placeholder="{{_ 'email-addresses'}}") + textarea#email-to-invite.wekan-form-control(rows='5', placeholder="{{_ 'email-addresses'}}") li .title {{_ 'to-boards'}} .bg-white @@ -63,20 +63,20 @@ template(name='email') .title {{_ 'smtp-host'}} .description {{_ 'smtp-host-description'}} .form-group - input.form-control#mail-server-host(type="text", placeholder="smtp.domain.com" value="{{currentSetting.mailServer.host}}") + input.wekan-form-control#mail-server-host(type="text", placeholder="smtp.domain.com" value="{{currentSetting.mailServer.host}}") li.smtp-form .title {{_ 'smtp-port'}} .description {{_ 'smtp-port-description'}} .form-group - input.form-control#mail-server-port(type="text", placeholder="25" value="{{currentSetting.mailServer.port}}") + input.wekan-form-control#mail-server-port(type="text", placeholder="25" value="{{currentSetting.mailServer.port}}") li.smtp-form .title {{_ 'smtp-username'}} .form-group - input.form-control#mail-server-username(type="text", placeholder="{{_ 'username'}}" value="{{currentSetting.mailServer.username}}") + input.wekan-form-control#mail-server-username(type="text", placeholder="{{_ 'username'}}" value="{{currentSetting.mailServer.username}}") li.smtp-form .title {{_ 'smtp-password'}} .form-group - input.form-control#mail-server-password(type="password", placeholder="{{_ 'password'}}" value="{{currentSetting.mailServer.password}}") + input.wekan-form-control#mail-server-password(type="password", placeholder="{{_ 'password'}}" value="{{currentSetting.mailServer.password}}") li.smtp-form .title {{_ 'smtp-tls'}} .form-group @@ -88,7 +88,7 @@ template(name='email') li.smtp-form .title {{_ 'send-from'}} .form-group - input.form-control#mail-server-from(type="email", placeholder="no-reply@domain.com" value="{{currentSetting.mailServer.from}}") + input.wekan-form-control#mail-server-from(type="email", placeholder="no-reply@domain.com" value="{{currentSetting.mailServer.from}}") li button.js-save.primary {{_ 'save'}} @@ -101,17 +101,17 @@ template(name='accountSettings') li.accounts-form .title {{_ 'accounts-allowEmailChange'}} .form-group.flex - input.form-control#accounts-allowEmailChange(type="radio" name="allowEmailChange" value="true" checked="{{#if allowEmailChange}}checked{{/if}}") + input.wekan-form-control#accounts-allowEmailChange(type="radio" name="allowEmailChange" value="true" checked="{{#if allowEmailChange}}checked{{/if}}") span {{_ 'yes'}} - input.form-control#accounts-allowEmailChange(type="radio" name="allowEmailChange" value="false" checked="{{#unless allowEmailChange}}checked{{/unless}}") + input.wekan-form-control#accounts-allowEmailChange(type="radio" name="allowEmailChange" value="false" checked="{{#unless allowEmailChange}}checked{{/unless}}") span {{_ 'no'}} li li.accounts-form .title {{_ 'accounts-allowUserNameChange'}} .form-group.flex - input.form-control#accounts-allowUserNameChange(type="radio" name="allowUserNameChange" value="true" checked="{{#if allowUserNameChange}}checked{{/if}}") + input.wekan-form-control#accounts-allowUserNameChange(type="radio" name="allowUserNameChange" value="true" checked="{{#if allowUserNameChange}}checked{{/if}}") span {{_ 'yes'}} - input.form-control#accounts-allowUserNameChange(type="radio" name="allowUserNameChange" value="false" checked="{{#unless allowUserNameChange}}checked{{/unless}}") + input.wekan-form-control#accounts-allowUserNameChange(type="radio" name="allowUserNameChange" value="false" checked="{{#unless allowUserNameChange}}checked{{/unless}}") span {{_ 'no'}} li button.js-accounts-save.primary {{_ 'save'}} @@ -128,7 +128,7 @@ template(name='announcementSettings') ul li .title {{_ 'admin-announcement-title'}} - textarea#admin-announcement.form-control= currentSetting.body + textarea#admin-announcement.wekan-form-control= currentSetting.body li button.js-announcement-save.primary {{_ 'save'}} @@ -137,16 +137,16 @@ template(name='layoutSettings') //li.layout-form .title {{_ 'hide-logo'}} .form-group.flex - input.form-control#hide-logo(type="radio" name="hideLogo" value="true" checked="{{#if currentSetting.hideLogo}}checked{{/if}}") + input.wekan-form-control#hide-logo(type="radio" name="hideLogo" value="true" checked="{{#if currentSetting.hideLogo}}checked{{/if}}") span {{_ 'yes'}} - input.form-control#hide-logo(type="radio" name="hideLogo" value="false" checked="{{#unless currentSetting.hideLogo}}checked{{/unless}}") + input.wekan-form-control#hide-logo(type="radio" name="hideLogo" value="false" checked="{{#unless currentSetting.hideLogo}}checked{{/unless}}") span {{_ 'no'}} li.layout-form .title {{_ 'display-authentication-method'}} .form-group.flex - input.form-control#display-authentication-method(type="radio" name="displayAuthenticationMethod" value="true" checked="{{#if currentSetting.displayAuthenticationMethod}}checked{{/if}}") + input.wekan-form-control#display-authentication-method(type="radio" name="displayAuthenticationMethod" value="true" checked="{{#if currentSetting.displayAuthenticationMethod}}checked{{/if}}") span {{_ 'yes'}} - input.form-control#display-authentication-method(type="radio" name="displayAuthenticationMethod" value="false" checked="{{#unless currentSetting.displayAuthenticationMethod}}checked{{/unless}}") + input.wekan-form-control#display-authentication-method(type="radio" name="displayAuthenticationMethod" value="false" checked="{{#unless currentSetting.displayAuthenticationMethod}}checked{{/unless}}") span {{_ 'no'}} li.layout-form .title {{_ 'default-authentication-method'}} @@ -154,13 +154,13 @@ template(name='layoutSettings') li.layout-form .title {{_ 'custom-product-name'}} .form-group - input.form-control#product-name(type="text", placeholder="" value="{{currentSetting.productName}}") + input.wekan-form-control#product-name(type="text", placeholder="" value="{{currentSetting.productName}}") li.layout-form .title {{_ 'add-custom-html-after-body-start'}} - textarea#customHTMLafterBodyStart.form-control= currentSetting.customHTMLafterBodyStart + textarea#customHTMLafterBodyStart.wekan-form-control= currentSetting.customHTMLafterBodyStart li.layout-form .title {{_ 'add-custom-html-before-body-end'}} - textarea#customHTMLbeforeBodyEnd.form-control= currentSetting.customHTMLbeforeBodyEnd + textarea#customHTMLbeforeBodyEnd.wekan-form-control= currentSetting.customHTMLbeforeBodyEnd li button.js-save-layout.primary {{_ 'save'}} diff --git a/client/components/settings/settingBody.styl b/client/components/settings/settingBody.styl index dbf91a6c..b9300782 100644 --- a/client/components/settings/settingBody.styl +++ b/client/components/settings/settingBody.styl @@ -105,14 +105,14 @@ .bg-white background #f9fbfc; -.form-control.has-error +.wekan-form-control.has-error border-color: #a94442; box-shadow: inset 0 1px 1px rgba(0,0,0,.075); li.has-error color #a94442 .form-group - .form-control + .wekan-form-control border-color: #a94442; box-shadow: inset 0 1px 1px rgba(0,0,0,.075); -- cgit v1.2.3-1-g7c22 From 8f899fca7286b076db7551fbbfd4434cf4b561e8 Mon Sep 17 00:00:00 2001 From: "Sam X. Chen" Date: Thu, 25 Jul 2019 16:26:33 -0400 Subject: Add Feature: User can have richer formatted text into the comments (unexpected theme changes fixed) --- client/components/main/editor.js | 3 ++- client/components/main/layouts.styl | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 0a6db12c..98461c4f 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,6 +1,7 @@ Template.editor.onRendered(() => { const textareaSelector = 'textarea'; - const enableRicherEditor = Meteor.settings.public.RICHER_EDITOR || true; + const enableRicherEditor = + Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR || true; const mentions = [ // User mentions { diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl index dafff251..672e4520 100644 --- a/client/components/main/layouts.styl +++ b/client/components/main/layouts.styl @@ -9,6 +9,28 @@ global-reset() .note-popover .popover-content .note-color-palette div .note-color-btn, .panel-heading.note-toolbar .note-color-palette div .note-color-btn background: none +a:focus + outline: unset + outline-offset: unset + +a:hover,a:focus + color: unset + text-decoration: unset + +.badge + display: unset + min-width: unset + padding: unset + font-size: unset + font-weight: unset + line-height: unset + color: unset + text-align: unset + white-space: unset + vertical-align: unset + background-color: unset + border-radius: unset + html, body, input, select, textarea, button font: 14px Roboto, "Helvetica Neue", Arial, Helvetica, sans-serif line-height: 18px -- cgit v1.2.3-1-g7c22