| @@ -0,0 +1,83 @@ | ||
| """Change UserSubscriptions table to Subscriptions | ||
| Revision ID: 209c3cd1a864 | ||
| Revises: 2246cd7f5801 | ||
| Create Date: 2014-10-24 13:19:15.932243 | ||
| """ | ||
| # revision identifiers, used by Alembic. | ||
| revision = '209c3cd1a864' | ||
| down_revision = '2246cd7f5801' | ||
| from alembic import op | ||
| import sqlalchemy as sa | ||
| from sqlalchemy.types import TypeDecorator, VARCHAR | ||
| class JSONEncodedDict(TypeDecorator): | ||
| """Represents an immutable structure as a json-encoded string. | ||
| Usage:: | ||
| JSONEncodedDict(255) | ||
| """ | ||
| # pylint: disable=too-many-public-methods | ||
| impl = VARCHAR | ||
| def process_bind_param(self, value, dialect): | ||
| if value is not None: | ||
| value = json.dumps(value) | ||
| return value | ||
| def process_result_value(self, value, dialect): | ||
| if value is not None: | ||
| value = json.loads(value) | ||
| return value | ||
| def python_type(self): | ||
| return dict | ||
| template_enum = sa.Enum('reply_notification', 'custom_search', | ||
| name="subscription_template") | ||
| type_enum = sa.Enum('system', 'user', | ||
| name="subscription_type") | ||
| def upgrade(): | ||
| op.drop_table('user_subscriptions') | ||
| op.create_table( | ||
| 'subscriptions', | ||
| sa.Column('id', sa.INTEGER, primary_key=True), | ||
| sa.Column('uri', sa.Unicode(256), nullable=False), | ||
| sa.Column('query', JSONEncodedDict(4096), nullable=True, default={}), | ||
| sa.Column('template', sa.VARCHAR(64), nullable=False), | ||
| sa.Column('parameters', JSONEncodedDict(1024), nullable=True, default={}), | ||
| sa.Column('description', sa.VARCHAR(256), default=""), | ||
| sa.Column('active', sa.BOOLEAN, default=True, nullable=False) | ||
| ) | ||
| def downgrade(): | ||
| op.drop_table('subscriptions') | ||
| op.create_table( | ||
| 'user_subscriptions', | ||
| sa.Column('id', sa.INTEGER, primary_key=True), | ||
| sa.Column( | ||
| 'username', | ||
| sa.Unicode(30), | ||
| sa.ForeignKey( | ||
| '%s.%s' % ('user', 'username'), | ||
| onupdate='CASCADE', | ||
| ondelete='CASCADE' | ||
| ), | ||
| nullable=False), | ||
| sa.Column('description', sa.VARCHAR(256), default=""), | ||
| sa.Column('template', template_enum, nullable=False, | ||
| default='custom_search'), | ||
| sa.Column('active', sa.BOOLEAN, default=True, nullable=False), | ||
| sa.Column('query', JSONEncodedDict(4096), nullable=False), | ||
| sa.Column('type', type_enum, nullable=False, default='user'), | ||
| ) |
| @@ -0,0 +1,7 @@ | ||
| # -*- coding: utf-8 -*- | ||
| def includeme(config): | ||
| config.include('.types') | ||
| config.include('.gateway') | ||
| config.include('.models') | ||
| config.include('.notifier') | ||
| config.include('.reply_template') |
| @@ -0,0 +1,109 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import re | ||
| import logging | ||
| from urlparse import urlparse | ||
| import requests | ||
| from bs4 import BeautifulSoup | ||
| from pyramid.events import subscriber | ||
| from pyramid.renderers import render | ||
| from pyramid.security import Everyone, principals_allowed_by_permission | ||
| from h import events | ||
| from h.notification.gateway import user_profile_url, standalone_url | ||
| from h.notification.notifier import send_email, TemplateRenderException | ||
| from h.notification.types import ROOT_PATH | ||
| log = logging.getLogger(__name__) # pylint: disable=invalid-name | ||
| TXT_TEMPLATE = ROOT_PATH + 'document_owner_notification.txt' | ||
| HTML_TEMPLATE = ROOT_PATH + 'document_owner_notification.pt' | ||
| SUBJECT_TEMPLATE = ROOT_PATH + 'document_owner_notification_subject.txt' | ||
| # ToDo: Turn this feature into uri based. | ||
| # Add page uris to the subscriptions table | ||
| # And then the domain mailer can be configured to separate web-pages | ||
| def create_template_map(request, annotation): | ||
| if 'tags' in annotation: | ||
| tags = '\ntags: ' + ', '.join(annotation['tags']) | ||
| else: | ||
| tags = '' | ||
| user = re.search("^acct:([^@]+)", annotation['user']).group(1) | ||
| return { | ||
| 'document_title': annotation['title'], | ||
| 'document_path': annotation['uri'], | ||
| 'text': annotation['text'], | ||
| 'tags': tags, | ||
| 'user_profile': user_profile_url(request, annotation['user']), | ||
| 'user': user, | ||
| 'path': standalone_url(request, annotation['id']), | ||
| 'timestamp': annotation['created'], | ||
| 'selection': annotation['quote'] | ||
| } | ||
| # TODO: Introduce proper cache for content parsing | ||
| def get_document_owners(content): | ||
| parsed_data = BeautifulSoup(content) | ||
| documents = parsed_data.select('a[rel="reply-to"]') | ||
| hrefs = [] | ||
| for d in documents: | ||
| if re.match(r'^mailto:', d['href'], re.IGNORECASE): | ||
| hrefs.append(d['href'][7:]) | ||
| return hrefs | ||
| # XXX: All below can be removed in the future after | ||
| # we can create a custom subscription for page uri | ||
| @subscriber(events.AnnotationEvent) | ||
| def domain_notification(event): | ||
| if event.action != 'create': | ||
| return | ||
| try: | ||
| annotation = event.annotation | ||
| request = event.request | ||
| # Check for authorization. Send notification only for public annotation | ||
| # XXX: This will be changed and fine grained when | ||
| # user groups will be introduced | ||
| allowed = principals_allowed_by_permission(annotation, 'read') | ||
| if Everyone not in allowed: | ||
| return | ||
| uri = annotation['uri'] | ||
| # TODO: Fetching the page should be done via a webproxy | ||
| r = requests.get(uri) | ||
| emails = get_document_owners(r.text) | ||
| # Now send the notifications | ||
| url_struct = urlparse(annotation['uri']) | ||
| domain = url_struct.hostname or url_struct.path | ||
| domain = re.sub(r'^www.', '', domain) | ||
| for email in emails: | ||
| # Domain matching | ||
| mail_domain = email.split('@')[-1] | ||
| if mail_domain == domain: | ||
| try: | ||
| # Render e-mail parts | ||
| tmap = create_template_map(request, annotation) | ||
| text = render(TXT_TEMPLATE, tmap, request) | ||
| html = render(HTML_TEMPLATE, tmap, request) | ||
| subject = render(SUBJECT_TEMPLATE, tmap, request) | ||
| send_email(request, subject, text, html, [email]) | ||
| # ToDo: proper exception handling here | ||
| except TemplateRenderException: | ||
| log.exception('Failed to render domain-mailer template') | ||
| except: | ||
| log.exception( | ||
| 'Unknown error when trying to render' | ||
| 'domain-mailer template') | ||
| except: | ||
| log.exception('Problem with domain notification') | ||
| def includeme(config): | ||
| config.scan(__name__) |
| @@ -0,0 +1,24 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import re | ||
| from h.auth.local import models | ||
| def user_name(user): | ||
| return re.search(r'^acct:([^@]+)', user).group(1) | ||
| def user_profile_url(request, user): | ||
| username = user_name(user) | ||
| return request.application_url + '/u/' + username | ||
| def standalone_url(request, annotation_id): | ||
| return request.application_url + '/a/' + annotation_id | ||
| def get_user_by_name(request, username): | ||
| return models.User.get_by_username(request, username) | ||
| def includeme(config): | ||
| pass |
| @@ -0,0 +1,115 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import logging | ||
| import json | ||
| import sqlalchemy as sa | ||
| from sqlalchemy.types import TypeDecorator, VARCHAR | ||
| from sqlalchemy.ext.declarative import declared_attr | ||
| from sqlalchemy import func, and_ | ||
| from pyramid_basemodel import Base | ||
| from hem.db import get_session | ||
| from horus.models import BaseModel | ||
| log = logging.getLogger(__name__) | ||
| class JSONEncodedDict(TypeDecorator): | ||
| """Represents an immutable structure as a json-encoded string. | ||
| Usage:: | ||
| JSONEncodedDict(255) | ||
| """ | ||
| # pylint: disable=too-many-public-methods | ||
| impl = VARCHAR | ||
| def process_bind_param(self, value, dialect): | ||
| if value is not None: | ||
| value = json.dumps(value) | ||
| return value | ||
| def process_result_value(self, value, dialect): | ||
| if value is not None: | ||
| value = json.loads(value) | ||
| return value | ||
| def python_type(self): | ||
| return dict | ||
| class SubscriptionsMixin(BaseModel): | ||
| # pylint: disable=no-self-use | ||
| @declared_attr | ||
| def __table_args__(self): | ||
| return sa.Index('subs_uri_idx_%s' % self.__tablename__, 'uri'), | ||
| @declared_attr | ||
| def uri(self): | ||
| return sa.Column( | ||
| sa.Unicode(256), | ||
| nullable=False | ||
| ) | ||
| @declared_attr | ||
| def query(self): | ||
| return sa.Column(JSONEncodedDict(4096), nullable=True, default={}) | ||
| @declared_attr | ||
| def template(self): | ||
| return sa.Column(sa.VARCHAR(64), nullable=False) | ||
| @declared_attr | ||
| def parameters(self): | ||
| return sa.Column(JSONEncodedDict(1024), nullable=True, default={}) | ||
| @declared_attr | ||
| def description(self): | ||
| return sa.Column(sa.VARCHAR(256), default="") | ||
| @declared_attr | ||
| def active(self): | ||
| return sa.Column(sa.BOOLEAN, default=True, nullable=False) | ||
| @classmethod | ||
| def get_active_subscriptions(cls, request): | ||
| session = get_session(request) | ||
| return session.query(cls).filter(cls.active).all() | ||
| @classmethod | ||
| def get_active_subscriptions_for_a_template(cls, request, template): | ||
| session = get_session(request) | ||
| return session.query(cls).filter( | ||
| and_( | ||
| cls.active, | ||
| func.lower(cls.template) == func.lower(template) | ||
| ) | ||
| ).all() | ||
| @classmethod | ||
| def get_subscriptions_for_uri(cls, request, uri): | ||
| session = get_session(request) | ||
| return session.query(cls).filter( | ||
| func.lower(cls.uri) == func.lower(uri) | ||
| ).all() | ||
| @classmethod | ||
| def get_a_template_for_uri(cls, request, uri, template): | ||
| session = get_session(request) | ||
| return session.query(cls).filter( | ||
| and_( | ||
| func.lower(cls.uri) == func.lower(uri), | ||
| func.lower(cls.template) == func.lower(template) | ||
| ) | ||
| ).all() | ||
| class Subscriptions(SubscriptionsMixin, Base): | ||
| pass | ||
| def includeme(config): | ||
| config.include('pyramid_basemodel') | ||
| config.include('pyramid_tm') | ||
| config.scan(__name__) |
| @@ -0,0 +1,22 @@ | ||
| # -*- coding: utf-8 -*- | ||
| from pyramid_mailer.interfaces import IMailer | ||
| from pyramid_mailer.message import Message | ||
| class TemplateRenderException(Exception): | ||
| pass | ||
| def send_email(request, subject, text, html, recipients): | ||
| body = text.decode('utf8') | ||
| mailer = request.registry.queryUtility(IMailer) | ||
| subject = subject.decode('utf8') | ||
| message = Message(subject=subject, | ||
| recipients=recipients, | ||
| body=body, | ||
| html=html) | ||
| mailer.send(message) | ||
| def includeme(config): | ||
| config.scan(__name__) |
| @@ -0,0 +1,197 @@ | ||
| # -*- coding: utf-8 -*- | ||
| import logging | ||
| from datetime import datetime | ||
| from pyramid.events import subscriber | ||
| from pyramid.security import Everyone, principals_allowed_by_permission | ||
| from pyramid.renderers import render | ||
| from hem.db import get_session | ||
| from horus.events import NewRegistrationEvent | ||
| from h.notification.notifier import send_email, TemplateRenderException | ||
| from h.notification import types | ||
| from h.notification.models import Subscriptions | ||
| from h.notification.gateway import user_name, \ | ||
| user_profile_url, standalone_url, get_user_by_name | ||
| from h.notification.types import ROOT_PATH | ||
| from h.events import LoginEvent, AnnotationEvent | ||
| from h import interfaces | ||
| log = logging.getLogger(__name__) # pylint: disable=invalid-name | ||
| TXT_TEMPLATE = ROOT_PATH + 'reply_notification.txt' | ||
| HTML_TEMPLATE = ROOT_PATH + 'reply_notification.pt' | ||
| SUBJECT_TEMPLATE = ROOT_PATH + 'reply_notification_subject.txt' | ||
| def parent_values(annotation, request): | ||
| if 'references' in annotation: | ||
| registry = request.registry | ||
| store = registry.queryUtility(interfaces.IStoreClass)(request) | ||
| parent = store.read(annotation['references'][-1]) | ||
| if 'references' in parent: | ||
| grandparent = store.read(parent['references'][-1]) | ||
| parent['quote'] = grandparent['text'] | ||
| return parent | ||
| else: | ||
| return {} | ||
| def create_template_map(request, reply, data): | ||
| document_title = '' | ||
| if 'document' in reply: | ||
| document_title = reply['document'].get('title', '') | ||
| parent_user = user_name(data['parent']['user']) | ||
| reply_user = user_name(reply['user']) | ||
| # Currently we cut the UTC format because time.strptime has problems | ||
| # parsing it, and of course it'd only correct the backend's timezone | ||
| # which is not meaningful for international users | ||
| date_format = '%Y-%m-%dT%H:%M:%S.%f' | ||
| parent_timestamp = datetime.strptime(data['parent']['created'][:-6], | ||
| date_format) | ||
| reply_timestamp = datetime.strptime(reply['created'][:-6], | ||
| date_format) | ||
| seq = ('http://', str(request.domain), | ||
| '/app?__formid__=unsubscribe&subscription_id=', | ||
| str(data['subscription']['id'])) | ||
| unsubscribe = "".join(seq) | ||
| return { | ||
| 'document_title': document_title, | ||
| 'document_path': data['parent']['uri'], | ||
| 'parent_text': data['parent']['text'], | ||
| 'parent_user': parent_user, | ||
| 'parent_timestamp': parent_timestamp, | ||
| 'parent_user_profile': user_profile_url( | ||
| request, data['parent']['user']), | ||
| 'parent_path': standalone_url(request, data['parent']['id']), | ||
| 'reply_text': reply['text'], | ||
| 'reply_user': reply_user, | ||
| 'reply_timestamp': reply_timestamp, | ||
| 'reply_user_profile': user_profile_url(request, reply['user']), | ||
| 'reply_path': standalone_url(request, reply['id']), | ||
| 'unsubscribe': unsubscribe | ||
| } | ||
| def get_recipients(request, data): | ||
| username = user_name(data['parent']['user']) | ||
| user_obj = get_user_by_name(request, username) | ||
| if not user_obj: | ||
| log.warn("User not found! " + str(username)) | ||
| raise TemplateRenderException('User not found') | ||
| return [user_obj.email] | ||
| def check_conditions(annotation, data): | ||
| # Get the e-mail of the owner | ||
| if 'user' not in data['parent'] or not data['parent']['user']: | ||
| return False | ||
| # Do not notify users about their own replies | ||
| if annotation['user'] == data['parent']['user']: | ||
| return False | ||
| # Is he the proper user? | ||
| if data['parent']['user'] != data['subscription']['uri']: | ||
| return False | ||
| # Else okay | ||
| return True | ||
| @subscriber(AnnotationEvent) | ||
| def send_notifications(event): | ||
| # Extract data | ||
| action = event.action | ||
| request = event.request | ||
| annotation = event.annotation | ||
| # And for them we need only the creation action | ||
| if action != 'create': | ||
| return | ||
| # Check for authorization. Send notification only for public annotation | ||
| # XXX: This will be changed and fine grained when | ||
| # user groups will be introduced | ||
| if Everyone not in principals_allowed_by_permission(annotation, 'read'): | ||
| return | ||
| # Store the parent values as additional data | ||
| data = { | ||
| 'parent': parent_values(annotation, request) | ||
| } | ||
| subscriptions = Subscriptions.get_active_subscriptions_for_a_template( | ||
| request, | ||
| types.REPLY_TEMPLATE | ||
| ) | ||
| for subscription in subscriptions: | ||
| data['subscription'] = { | ||
| 'id': subscription.id, | ||
| 'uri': subscription.uri, | ||
| 'parameters': subscription.parameters, | ||
| 'query': subscription.query | ||
| } | ||
| # Validate annotation | ||
| if check_conditions(annotation, data): | ||
| try: | ||
| # Render e-mail parts | ||
| tmap = create_template_map(request, annotation, data) | ||
| text = render(TXT_TEMPLATE, tmap, request) | ||
| html = render(HTML_TEMPLATE, tmap, request) | ||
| subject = render(SUBJECT_TEMPLATE, tmap, request) | ||
| recipients = get_recipients(request, data) | ||
| send_email(request, subject, text, html, recipients) | ||
| # ToDo: proper exception handling here | ||
| except TemplateRenderException: | ||
| log.exception('Failed to render subscription' | ||
| ' template %s', subscription) | ||
| except: | ||
| log.exception('Unknown error when trying to render' | ||
| ' subscription template %s', subscription) | ||
| # Create a reply template for a uri | ||
| def create_subscription(request, uri, active): | ||
| session = get_session(request) | ||
| subs = Subscriptions( | ||
| uri=uri, | ||
| template=types.REPLY_TEMPLATE, | ||
| description='General reply notification', | ||
| active=active | ||
| ) | ||
| session.add(subs) | ||
| session.flush() | ||
| @subscriber(NewRegistrationEvent) | ||
| def registration_subscriptions(event): | ||
| request = event.request | ||
| user_uri = 'acct:{}@{}'.format(event.user.username, request.domain) | ||
| create_subscription(event.request, user_uri, True) | ||
| event.user.subscriptions = True | ||
| # For backwards compatibility, generate reply notification if not exists | ||
| @subscriber(LoginEvent) | ||
| def check_reply_subscriptions(event): | ||
| request = event.request | ||
| user_uri = 'acct:{}@{}'.format(event.user.username, request.domain) | ||
| res = Subscriptions.get_a_template_for_uri( | ||
| request, | ||
| user_uri, | ||
| types.REPLY_TEMPLATE | ||
| ) | ||
| if not len(res): | ||
| create_subscription(event.request, user_uri, True) | ||
| event.user.subscriptions = True | ||
| def includeme(config): | ||
| config.scan(__name__) |
| @@ -0,0 +1,13 @@ | ||
| <p>> ${selection} | ||
| ${text} | ||
| ${tags} | ||
| From: | ||
| ${user} | ||
| ${user_profile} | ||
| On ${timestamp} at (${path}) | ||
| About ${document_path} | ||
| -- | ||
| This is an annotation on your document from Hypothes.is / Annotator (URL here)</p> |
| @@ -0,0 +1,9 @@ | ||
| # -*- coding: utf-8 -*- | ||
| # Notification Template types | ||
| REPLY_TEMPLATE = 'reply template' | ||
| DOCUMENT_OWNER = 'document_owner' | ||
| ROOT_PATH = 'h:notification/templates/' | ||
| def includeme(config): | ||
| pass |
| @@ -0,0 +1,13 @@ | ||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||
| <svg width="60px" height="45px" viewBox="0 0 60 45" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"> | ||
| <!-- Generator: Sketch 3.1.1 (8761) - http://www.bohemiancoding.com/sketch --> | ||
| <title>Slice 2</title> | ||
| <desc>Created with Sketch.</desc> | ||
| <defs></defs> | ||
| <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> | ||
| <g id="Path-1-+-Triangle-1" sketch:type="MSLayerGroup" transform="translate(2.000000, -2.000000)"> | ||
| <path d="M0.273888323,46.8543613 C0.792991324,12.7899443 27.9928896,9.26957714 53.1377225,6.24948249" id="Path-1" stroke="#979797" stroke-width="2" sketch:type="MSShapeGroup"></path> | ||
| <polygon id="Triangle-1" fill="#979797" sketch:type="MSShapeGroup" transform="translate(51.500000, 6.500000) rotate(82.000000) translate(-51.500000, -6.500000) " points="51.5 1 57 12 46 12 "></polygon> | ||
| </g> | ||
| </g> | ||
| </svg> |
| @@ -0,0 +1,162 @@ | ||
| @import "./mixins/icons"; | ||
| .help-page { | ||
| padding-top: 2.5em; | ||
| padding-bottom: 2.5em; | ||
| background: white; | ||
| @include breakpoint(920px) { | ||
| padding-right: 460px; | ||
| } | ||
| .masthead { | ||
| margin-bottom: 2.5em; | ||
| } | ||
| } | ||
| .help-page-content { | ||
| margin: auto; | ||
| padding: 0 20px; | ||
| min-width: 480px; | ||
| @include breakpoint(1160px) { | ||
| padding: 0 5% 0 10%; | ||
| } | ||
| @include breakpoint(1400px) { | ||
| padding: 0 10% 0 20%; | ||
| } | ||
| } | ||
| .help-page-heading { | ||
| color: $gray-darker; | ||
| margin-bottom: 1em; | ||
| font-size: 1.5em; | ||
| } | ||
| .help-page-heading, | ||
| .help-page-lede { | ||
| text-align: center; | ||
| } | ||
| .help-page-lede { | ||
| font-style: italic; | ||
| margin-bottom: 2.5em; | ||
| } | ||
| .help-page-section { | ||
| padding: 2.5em; | ||
| border-top: 1px solid #EAEAEA; | ||
| &:first-child { | ||
| border-top: none; | ||
| padding-top: 0; | ||
| } | ||
| } | ||
| .help-page-sidebar { | ||
| position: fixed; | ||
| top: 20px; | ||
| right: 2.5em; | ||
| bottom: 20px; | ||
| width: 380px; | ||
| display: block; | ||
| @include breakpoint(920px) { | ||
| border: $gray-lighter dotted 2px; | ||
| border-radius: 3px; | ||
| } | ||
| } | ||
| @mixin help-icon { | ||
| border: 1px solid rgba(0, 0, 0, 0.2); | ||
| padding: 0.4em; | ||
| border-radius: 0.4em; | ||
| background: #FFF; | ||
| text-shadow: 0 0 2px #F9F9F9, 0 0 0 #777; | ||
| color: rgba(200, 200, 200, 0.3); | ||
| font-size: 10px; | ||
| } | ||
| #help-1 { | ||
| position: fixed; | ||
| top: 60px; | ||
| right: 60px; | ||
| width: 210px; | ||
| color: $gray-light; | ||
| text-align: right; | ||
| @include icons { | ||
| @include help-icon; | ||
| font-size: 14px; | ||
| } | ||
| } | ||
| #help-2 { | ||
| background: url(../images/help-arrow.svg) 0 0 no-repeat; | ||
| width: 60px; | ||
| height: 45px; | ||
| position: absolute; | ||
| top: -10px; | ||
| right: -10px; | ||
| } | ||
| .numbered-list { | ||
| counter-reset: numbered-list; | ||
| } | ||
| .numbered-list-item { | ||
| position: relative; | ||
| counter-increment: numbered-list; | ||
| padding-left: 3em; | ||
| padding-right: 1.25em; | ||
| margin-bottom: 2.5em; | ||
| list-style-type: none; | ||
| &:before { | ||
| content: counter(numbered-list); | ||
| display: block; | ||
| position: absolute; | ||
| top: .125em; | ||
| left: 0; | ||
| width: 2.125em; | ||
| height: 1.8125em; | ||
| border: .125em solid $hypothered; | ||
| border-radius: 50%; | ||
| text-align: center; | ||
| padding-top: .3125em; // 24px == Line height of text. | ||
| } | ||
| } | ||
| .feature { | ||
| margin-bottom: 1.5em; | ||
| } | ||
| .feature-heading { | ||
| color: $gray-darker; | ||
| font-size: 1.125em; | ||
| margin-bottom: .555em; | ||
| } | ||
| .feature-icon { | ||
| font-size: .875em; | ||
| margin-right: .3em; | ||
| } | ||
| .help-icon { | ||
| @include help-icon; | ||
| } | ||
| .bookmarklet { | ||
| white-space: nowrap; | ||
| color: $gray-darker; | ||
| padding: 2px 4px; | ||
| border: 1px solid; | ||
| border-radius: 2px; | ||
| font-size: 13px; | ||
| cursor: move; | ||
| @include icons { | ||
| font-size: 12px; | ||
| } | ||
| } |