├── .eslintrc ├── .gitignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── @types ├── freeze-dry │ └── index.d.ts ├── markdown-it │ ├── index.d.ts │ ├── parser_block.d.ts │ ├── parser_core.d.ts │ ├── parser_inline.d.ts │ ├── renderer.d.ts │ ├── ruler.d.ts │ ├── rules_block │ │ └── state_block.d.ts │ ├── rules_core │ │ └── state_core.d.ts │ ├── rules_inline │ │ └── state_inline.d.ts │ └── token.d.ts ├── readability │ └── index.d.ts ├── rehype-raw │ └── index.d.ts ├── rehype-stringify │ └── index.d.ts ├── remark-inline-links │ └── index.d.ts ├── remark-rehype │ └── index.d.ts ├── remark-unlink │ └── index.d.ts ├── remark │ └── index.d.ts └── turndown-plugin-gfm │ └── index.d.ts ├── README.md ├── package.json ├── public ├── compass-solid.svg ├── content.css ├── disable-icon-128.png ├── double-dagger.png ├── double-dagger.svg ├── file-alt-solid.svg ├── icon-128.png ├── icon-128.svg ├── icon-off.png ├── icon-off.svg ├── icon-on.png ├── link-solid.svg ├── manifest.json ├── md-icon.svg ├── tachyons.min.css ├── ui.css ├── ui.html └── website-icon.svg ├── src ├── background.ts ├── backlinks.ts ├── blob-reader.ts ├── content.ts ├── data.ts ├── dom │ └── selection.ts ├── effect.ts ├── gql.ts ├── ingest.ts ├── iterable.ts ├── links.ts ├── mailbox.ts ├── mode.ts ├── port.ts ├── program.ts ├── protocol.ts ├── remark.ts ├── runtime.ts ├── scanner.ts ├── scraper.ts ├── siblinks.ts ├── simlinks.ts ├── thumb.ts ├── turn-down.ts ├── ui.ts ├── url.ts └── view │ ├── html.ts │ ├── list.ts │ ├── resource.ts │ └── util.ts ├── tsconfig.json ├── webpack.config.ts └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "globals": { 8 | "__static": true 9 | }, 10 | "parser": "@typescript-eslint/parser", 11 | "extends": [ 12 | "airbnb-typescript", 13 | "plugin:@typescript-eslint/recommended", 14 | "prettier/@typescript-eslint", 15 | "plugin:prettier/recommended" 16 | ], 17 | "rules": { 18 | // we agreed we don't like semicolons 19 | "semi": ["error", "never"], 20 | 21 | // we agreed class methods don't have to use "this". 22 | "class-methods-use-this": "off", 23 | 24 | // we like dangling commas 25 | "comma-dangle": "off", 26 | 27 | // we agreed we don't require default cases 28 | "default-case": "off", 29 | 30 | // we agreed that we're okay with idiomatic short-circuits 31 | "no-unused-expressions": [ 32 | "error", 33 | { 34 | "allowShortCircuit": true 35 | } 36 | ], 37 | 38 | // we agreed this feels unnecessarily opinionated 39 | "lines-between-class-members": "off", 40 | 41 | // arguably we should not do this, but we do, 18 times 42 | "no-shadow": "off", 43 | // arguably we should not do this, but there are 70 cases where we do 44 | "no-param-reassign": "off", 45 | 46 | // third-party libs often use this 47 | "no-underscore-dangle": "off", 48 | 49 | // we agreed this is a bad rule 50 | "react/destructuring-assignment": "off", 51 | 52 | // we agreed this is gratuitous 53 | "react/jsx-one-expression-per-line": "off", 54 | 55 | // pushpin is inherently visual, so we've disabled quite a few accessibility rules 56 | // it would be reasonable to re-enable these but would take some work 57 | "jsx-a11y/no-static-element-interactions": "off", 58 | "jsx-a11y/anchor-is-valid": "off", 59 | "jsx-a11y/interactive-supports-focus": "off", 60 | "jsx-a11y/no-noninteractive-tabindex": "off", 61 | "jsx-a11y/click-events-have-key-events": "off", 62 | "jsx-a11y/no-autofocus": "off", 63 | "jsx-a11y/media-has-caption": "off", // randomly sourced audio doesn't come captioned 64 | 65 | // we might want to do this, but there are 97 cases where we don't 66 | "@typescript-eslint/explicit-member-accessibility": "off", 67 | 68 | // we might want to this, but there are 424 places we don't 69 | "@typescript-eslint/explicit-function-return-type": "off", 70 | 71 | // we agreed this rule is gratuitious 72 | "@typescript-eslint/no-use-before-define": "off", 73 | 74 | // someday, we should turn this back on, but we use it 44 times 75 | "@typescript-eslint/no-explicit-any": "off", 76 | 77 | // sometimes third-party libs are typed incorrectly 78 | "@typescript-eslint/no-non-null-assertion": "off", 79 | 80 | // we agreed unused arguments should be left in-place and not removed 81 | "@typescript-eslint/no-unused-vars": [ 82 | "error", 83 | { 84 | "args": "none" 85 | } 86 | ], 87 | 88 | // import-specific rulings 89 | // we probably want to enable this, and it's violated 23 times 90 | "import/no-extraneous-dependencies": "off", 91 | 92 | // we probably don't like this rule, but only Content violates it, so we could have it 93 | "import/no-named-as-default-member": "off", 94 | 95 | // we agreed it's better to be consistent in how you export than follow this rule 96 | "import/prefer-default-export": "off", 97 | 98 | // tsc handles this better, and allows for multiple typed exports of the same name 99 | "import/export": "off", 100 | 101 | // we agreed we don't really care about this rule 102 | "import/no-cycle": "off" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | web-ext-artifacts/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "arrowParens": "always", 4 | "trailingComma": "es5", 5 | "tabWidth": 2, 6 | "semi": false, 7 | "singleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2, 3 | "editor.formatOnSave": true, 4 | "editor.rulers": [100], 5 | "eslint.enable": true, 6 | "eslint.autoFixOnSave": true, 7 | "eslint.validate": [ 8 | { "language": "javascript", "autoFix": true }, 9 | { "language": "javascriptreact", "autoFix": true }, 10 | { "language": "typescript", "autoFix": true }, 11 | { "language": "typescriptreact", "autoFix": true } 12 | ], 13 | "typescript.tsdk": "node_modules/typescript/lib", 14 | "editor.codeActionsOnSave": { 15 | "source.fixAll.eslint": true 16 | }, 17 | "files.exclude": { 18 | "dist": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /@types/freeze-dry/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'freeze-dry' { 2 | type Options = { 3 | signal?: AbortSignal | undefined | null 4 | resolveURL?: any 5 | timeout?: number 6 | docUrl?: string 7 | addMetadata?: boolean 8 | keepOriginalAttributes?: boolean 9 | now?: Date 10 | fetchResource?: ( 11 | input: RequestInfo, 12 | init?: RequestInit | undefined 13 | ) => Promise | Promise<{ blob: Blob; url: string }> 14 | } 15 | 16 | declare function freezeDry(document: Document, options: Options) 17 | 18 | export default freezeDry 19 | } 20 | -------------------------------------------------------------------------------- /@types/markdown-it/index.d.ts: -------------------------------------------------------------------------------- 1 | // import { LinkifyIt } from 'linkify-it' 2 | 3 | // import State from './rules_core/state_core' 4 | // import StateBlock from './rules_block/state_block' 5 | // import StateInline from './rules_inline/state_inline' 6 | 7 | // import Core from './parser_core' 8 | // import ParserBlock from './parser_block' 9 | // import ParserInline from './parser_inline' 10 | 11 | // import Renderer from './renderer' 12 | // import Ruler from './ruler' 13 | // import Token from './token' 14 | 15 | declare module 'markdown-it' { 16 | export interface Options { 17 | html?: boolean 18 | xhtmlOut?: boolean 19 | breaks?: boolean 20 | langPrefix?: string 21 | linkify?: boolean 22 | typographer?: boolean 23 | quotes?: string 24 | highlight?: (str: string, lang: string) => void 25 | } 26 | 27 | export interface Rule { 28 | (state: S, silent?: boolean): boolean | void 29 | } 30 | 31 | export interface RuleInline extends Rule {} 32 | export interface RuleBlock extends Rule {} 33 | 34 | export interface RulerInline extends Ruler {} 35 | export interface RulerBlock extends Ruler {} 36 | 37 | export type TokenRender = ( 38 | tokens: Token[], 39 | index: number, 40 | options: any, 41 | env: any, 42 | self: Renderer 43 | ) => string 44 | 45 | export interface Delimiter { 46 | close: boolean 47 | end: number 48 | jump: number 49 | length: number 50 | level: number 51 | marker: number 52 | open: boolean 53 | token: number 54 | } 55 | 56 | declare class MarkdownIt { 57 | constructor() 58 | constructor(presetName: 'commonmark' | 'zero' | 'default', options?: Options) 59 | constructor(options: Options) 60 | 61 | render(md: string, env?: any): string 62 | renderInline(md: string, env?: any): string 63 | parse(src: string, env: any): Token[] 64 | parseInline(src: string, env: any): Token[] 65 | 66 | /* 67 | // The following only works in 3.0 68 | // Since it's still not allowed to target 3.0, i'll leave the code commented out 69 | 70 | use = any[]>( 71 | plugin: (md: MarkdownIt, ...params: T) => void, 72 | ...params: T 73 | ): MarkdownIt; 74 | */ 75 | 76 | use(plugin: (md: MarkdownIt, ...params: any[]) => void, ...params: any[]): MarkdownIt 77 | 78 | utils: { 79 | assign(obj: any): any 80 | isString(obj: any): boolean 81 | has(object: any, key: string): boolean 82 | unescapeMd(str: string): string 83 | unescapeAll(str: string): string 84 | isValidEntityCode(str: any): boolean 85 | fromCodePoint(str: string): string 86 | escapeHtml(str: string): string 87 | arrayReplaceAt(src: any[], pos: number, newElements: any[]): any[] 88 | isSpace(str: any): boolean 89 | isWhiteSpace(str: any): boolean 90 | isMdAsciiPunct(str: any): boolean 91 | isPunctChar(str: any): boolean 92 | escapeRE(str: string): string 93 | normalizeReference(str: string): string 94 | } 95 | 96 | disable(rules: string[] | string, ignoreInvalid?: boolean): MarkdownIt 97 | enable(rules: string[] | string, ignoreInvalid?: boolean): MarkdownIt 98 | set(options: MarkdownIt.Options): MarkdownIt 99 | normalizeLink(url: string): string 100 | normalizeLinkText(url: string): string 101 | validateLink(url: string): boolean 102 | block: ParserBlock 103 | core: Core 104 | helpers: any 105 | inline: ParserInline 106 | linkify: LinkifyIt 107 | renderer: Renderer 108 | } 109 | 110 | export default MarkdownIt 111 | } 112 | -------------------------------------------------------------------------------- /@types/markdown-it/parser_block.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require("."); 2 | import Token = require("./token"); 3 | 4 | export = ParserBlock; 5 | 6 | declare class ParserBlock { 7 | parse(src: string, md: MarkdownIt, env: any, outTokens: Token[]): void; 8 | ruler: MarkdownIt.RulerBlock; 9 | } 10 | -------------------------------------------------------------------------------- /@types/markdown-it/parser_core.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require("."); 2 | import Ruler = require("./ruler"); 3 | import Token = require("./token"); 4 | import StateCore = require("./rules_core/state_core"); 5 | 6 | export = ParserCore; 7 | 8 | declare class ParserCore { 9 | process(state: any): void; 10 | ruler: Ruler; 11 | State: StateCore 12 | } 13 | -------------------------------------------------------------------------------- /@types/markdown-it/parser_inline.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require("."); 2 | import State = require("./rules_core/state_core"); 3 | import Token = require("./token"); 4 | 5 | export = ParserInline; 6 | 7 | declare class ParserInline { 8 | parse(src: string, md: MarkdownIt, env: any, outTokens: Token[]): void; 9 | tokenize(state: State): void; 10 | skipToken(state: State): void; 11 | ruler: MarkdownIt.RulerInline; 12 | ruler2: MarkdownIt.RulerInline; 13 | } 14 | -------------------------------------------------------------------------------- /@types/markdown-it/renderer.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require("."); 2 | import Token = require("./token"); 3 | 4 | export = Renderer; 5 | 6 | declare class Renderer { 7 | rules: { [name: string]: MarkdownIt.TokenRender }; 8 | render(tokens: Token[], options: any, env: any): string; 9 | renderAttrs(token: Token): string; 10 | renderInline(tokens: Token[], options: any, env: any): string; 11 | renderToken(tokens: Token[], idx: number, options: any): string; 12 | } 13 | -------------------------------------------------------------------------------- /@types/markdown-it/ruler.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require("."); 2 | import State = require("./rules_core/state_core"); 3 | 4 | export = Ruler; 5 | 6 | declare class Ruler { 7 | after(afterName: string, ruleName: string, rule: MarkdownIt.Rule, options?: any): void; 8 | at(name: string, rule: MarkdownIt.Rule, options?: any): void; 9 | before(beforeName: string, ruleName: string, rule: MarkdownIt.Rule, options?: any): void; 10 | disable(rules: string | string[], ignoreInvalid?: boolean): string[]; 11 | enable(rules: string | string[], ignoreInvalid?: boolean): string[]; 12 | enableOnly(rule: string, ignoreInvalid?: boolean): void; 13 | getRules(chain: string): MarkdownIt.Rule[]; 14 | push(ruleName: string, rule: MarkdownIt.Rule, options?: any): void; 15 | } 16 | -------------------------------------------------------------------------------- /@types/markdown-it/rules_block/state_block.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require(".."); 2 | import State = require("../rules_core/state_core"); 3 | import Token = require("../token"); 4 | 5 | export = StateBlock; 6 | 7 | declare class StateBlock extends State { 8 | /** Used in lists to determine if they interrupt a paragraph */ 9 | parentType: 'blockquote' | 'list' | 'root' | 'paragraph' | 'reference'; 10 | 11 | eMarks: number[]; 12 | bMarks: number[]; 13 | bsCount: number[]; 14 | sCount: number[]; 15 | tShift: number[]; 16 | 17 | blkIndent: number; 18 | ddIndent: number; 19 | 20 | line: number; 21 | lineMax: number; 22 | tight: boolean; 23 | } 24 | -------------------------------------------------------------------------------- /@types/markdown-it/rules_core/state_core.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require(".."); 2 | import Token = require("../token"); 3 | 4 | export = StateCore; 5 | 6 | declare class StateCore { 7 | 8 | constructor(src: string, md: MarkdownIt, env: any) 9 | 10 | env: any; 11 | level: number; 12 | 13 | /** Link to parser instance */ 14 | md: MarkdownIt; 15 | 16 | /** The markdown source code that is being parsed. */ 17 | src: string; 18 | 19 | tokens: Token[]; 20 | 21 | /** Return any for a yet untyped property */ 22 | [undocumented: string]: any; 23 | } 24 | -------------------------------------------------------------------------------- /@types/markdown-it/rules_inline/state_inline.d.ts: -------------------------------------------------------------------------------- 1 | import MarkdownIt = require(".."); 2 | import State = require("../rules_core/state_core"); 3 | import Token = require("../token"); 4 | 5 | export = StateInline; 6 | 7 | declare class StateInline extends State { 8 | /** 9 | * Stores `{ start: end }` pairs. Useful for backtrack 10 | * optimization of pairs parse (emphasis, strikes). 11 | */ 12 | cache: { [start: number]: number }; 13 | 14 | /** Emphasis-like delimiters */ 15 | delimiters: MarkdownIt.Delimiter[]; 16 | 17 | pending: string; 18 | pendingLevel: number; 19 | 20 | /** Index of the first character of this token. */ 21 | pos: number; 22 | 23 | /** Index of the last character that can be used (for example the one before the end of this line). */ 24 | posMax: number; 25 | 26 | /** 27 | * Push new token to "stream". 28 | * If pending text exists, flush it as text token. 29 | */ 30 | push(type: string, tag: string, nesting: number): Token; 31 | 32 | /** Flush pending text */ 33 | pushPending(): Token; 34 | 35 | /** 36 | * Scan a sequence of emphasis-like markers and determine whether 37 | * it can start an emphasis sequence or end an emphasis sequence. 38 | * @param start - position to scan from (it should point to a valid marker) 39 | * @param canSplitWord - determine if these markers can be found inside a word 40 | */ 41 | scanDelims(start: number, canSplitWord: boolean): { 42 | can_open: boolean, 43 | can_close: boolean, 44 | length: number 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /@types/markdown-it/token.d.ts: -------------------------------------------------------------------------------- 1 | export = Token; 2 | 3 | declare class Token { 4 | constructor(type: string, tag: string, nesting: number); 5 | attrGet: (name: string) => string | null; 6 | attrIndex: (name: string) => number; 7 | attrJoin: (name: string, value: string) => void; 8 | attrPush: (attrData: string[]) => void; 9 | attrSet: (name: string, value: string) => void; 10 | attrs: string[][]; 11 | block: boolean; 12 | children: Token[]; 13 | content: string; 14 | hidden: boolean; 15 | info: string; 16 | level: number; 17 | map: number[]; 18 | markup: string; 19 | meta: any; 20 | nesting: number; 21 | tag: string; 22 | type: string; 23 | } 24 | -------------------------------------------------------------------------------- /@types/readability/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'readability/Readability' { 2 | export type Options = { 3 | documentElement?: HTMLElement 4 | debug?: boolean 5 | maxElemsToParse?: number 6 | nbTopCandidates?: number 7 | charThreshold?: number 8 | classesToPreserve?: string[] 9 | keepClasses?: boolean 10 | } 11 | 12 | export type Data = { 13 | title: string 14 | byline: string 15 | length: number 16 | content: string 17 | excerpt: string 18 | dir: { [string]: string } 19 | } 20 | 21 | declare class Readability { 22 | constructor(document: HTMLDocument, options?: Options) 23 | parse(): Data 24 | } 25 | 26 | export default Readability 27 | } 28 | -------------------------------------------------------------------------------- /@types/rehype-raw/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'rehype-raw' { 2 | import { RemarkPlugin } from 'remark' 3 | declare var raw: RemarkPlugin 4 | export default raw 5 | } 6 | -------------------------------------------------------------------------------- /@types/rehype-stringify/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'rehype-stringify' { 2 | import { RemarkPlugin } from 'remark' 3 | declare var stringify: RemarkPlugin 4 | export default stringify 5 | } 6 | -------------------------------------------------------------------------------- /@types/remark-inline-links/index.d.ts: -------------------------------------------------------------------------------- 1 | import { RemarkPlugin } from 'remark' 2 | 3 | declare module 'remark-inline-links' { 4 | declare var relink: RemarkPlugin<{ unlinkBrokenLinks: boolean }> 5 | export default relink 6 | } 7 | -------------------------------------------------------------------------------- /@types/remark-rehype/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'remark-rehype' { 2 | import { RemarkPlugin } from 'remark' 3 | declare var rehype: RemarkPlugin<{ allowDangerousHtml: boolean }> 4 | export default rehype 5 | } 6 | -------------------------------------------------------------------------------- /@types/remark-unlink/index.d.ts: -------------------------------------------------------------------------------- 1 | import { RemarkPlugin } from 'remark' 2 | 3 | declare module 'remark-unlink' { 4 | declare var unlink: RemarkPlugin 5 | export default unlink 6 | } 7 | -------------------------------------------------------------------------------- /@types/remark/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'remark' { 2 | export interface RemarkPlugin { 3 | (options: a): RemarkPlugin 4 | new (options: a): RemarkPlugin 5 | } 6 | 7 | export interface VFile { 8 | toString(): string 9 | } 10 | 11 | export interface Remark { 12 | use(plugin: RemarkPlugin, options: a): Remark 13 | use(plugin: RemarkPlugin): Remark 14 | 15 | process(content: string): Promise 16 | processSync(content: string): VFile 17 | } 18 | declare function remark(): Remark 19 | 20 | export default remark 21 | } 22 | -------------------------------------------------------------------------------- /@types/turndown-plugin-gfm/index.d.ts: -------------------------------------------------------------------------------- 1 | import { TurndownPlugin } from 'turndown' 2 | 3 | declare module 'turndown-plugin-gfm' { 4 | declare var strikethrough: TurndownPlugin 5 | declare var tables: TurndownPlugin 6 | declare var taskListItems: TurndownPlugin 7 | declare var gfm: TurndownPlugin 8 | 9 | export { strikethrough, tables, taskListItems, gfm } 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ‡ (diesis) 2 | 3 | Diesis enhances your browser to show you the hidden connections between your information. 4 | 5 | As you browse the internet, you'll be able to see whether other pages you've visited link to the page you're on, whether you've seen links before (even if you haven't traveled them), and you can even query that archive with text to find related content. 6 | 7 | It does this by storing a local copy of the pages you browse and extracting their links, and keywords (using an algorithm called tf-idf). No data ever leaves your computer. There is no cloud service, and no subscription, and only local processes can access it. 8 | 9 | *Warning: This is an experimental piece of research software presented for transparency, and is incomplete, unsupported, and likely to stop working.* 10 | 11 | 12 | ## Installing 13 | 14 | Diesis has two pieces, a browser plugin and a local daemon that stores the data. You'll need both halves to use diesis successfully. 15 | 16 | ### Browser plugin 17 | 18 | The browser plugin is compatible with Chrome and Firefox, but you'll need to compile it yourself. You can do that by checking out the repository and running: 19 | 20 | $ yarn 21 | $ yarn build 22 | 23 | The the result will be an "unpacked" extension in the `./dist` directory, which you can load in Chrome by going to [chrome://extensions](chrome://extensions), and clicking "Load Unpacked" (you may have to toggle on Developer Mode!), then pointing the dialog at the `dist` directory. 24 | 25 | ### Local daemon 26 | 27 | The daemon is a separate component because it can be used without diesis. Check out the code and start the daemon: 28 | 29 | $ git clone https://github.com/inkandswitch/ksp.git 30 | $ cargo +nightly build 31 | $ .\target\debug\knowledge-server serve 32 | 33 | ## Usage 34 | 35 | Diesis collects and archives the text and links on webpages you view, and resurfaces that information later at opportune moments. If Diesis has detected a relationship between the page you're looking at and a page you've previously viewed, the extension icon will include a number showing the number of connections it has discovered. Diesis will also surface links you've seen before on other pages (siblinks), and it will also allow you to query your local data for other pages with similar content by submitting selected text (this is particularly useful for jargon and names). 36 | 37 | ### Backlinks 38 | 39 | A backlink is a link to this page from somewhere else you've been. It might be a blog post you read, or another page on the same site. It could also be a link saved in a local note on your computer. 40 | 41 | ### Siblinks 42 | 43 | A sib-link is a link you've seen somewhere before. The view will show you summaries of other pages where you've seen this link before. This is particularly useful for reference links, such as commonly-linked documentation, blog posts, or similar. 44 | 45 | ### Simlinks 46 | 47 | Simlinks are pages with similar content to your current query. 48 | 49 | ### Known Bugs 50 | 51 | Diesis sometimes conflicts with webpages. If it's causing problems, you can disable diesis by navigating to [chrome://extensions] and toggling the extension to off. This will stop sending and querying your KSP server. The extension will remain off unless reenabled. 52 | 53 | ### Wishlist 54 | 55 | While the research-trails project is over, if you're interested in contributing, there are some features we didn't get to in the GitHub Issues page. Feel free to inquire on any of them and if one of us has time, we'll help you figure out how to pursue it. 56 | 57 | ## Credits 58 | 59 | An Ink & Switch joint. Part of the `research-trails` project, along with xcrpt and ksp. 60 | 61 | Software by Irakli Gozalishvili & Peter van Hardenberg 62 | 63 | Based on Pushpin Clipper by Peter van Hardenberg 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cont-ext", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "github.com/inkandswitch/cont-ext", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "webpack", 9 | "watch": "webpack -w", 10 | "artifact": "web-ext build -s dist -o" 11 | }, 12 | "dependencies": { 13 | "lit-html": "1.2.1", 14 | "markdown-it": "10.0.0", 15 | "readability": "git://github.com/mozilla/readability.git", 16 | "rehype-raw": "4.0.2", 17 | "rehype-stringify": "7.0.0", 18 | "remark": "12.0.0", 19 | "remark-inline-links": "git://github.com/gozala/remark-inline-links.git", 20 | "remark-rehype": "6.0.0", 21 | "turndown": "6.0.0", 22 | "turndown-plugin-gfm": "1.0.2" 23 | }, 24 | "devDependencies": { 25 | "@types/chrome": "^0.0.89", 26 | "@types/copy-webpack-plugin": "^5.0.0", 27 | "@types/markdown-it": "file:./@types/markdown-it", 28 | "@types/node": "^12.7.11", 29 | "@types/readability": "file:./@types/readability", 30 | "@types/rehype-raw": "file:./@types/rehype-raw", 31 | "@types/rehype-stringify": "file:./@types/rehype-stringify", 32 | "@types/remark": "file:./@types/remark", 33 | "@types/remark-inline-links": "file:./@types/remark-inline-links", 34 | "@types/remark-rehype": "file:./@types/remark-rehype", 35 | "@types/remark-unlink": "file:./@types/remark-unlink", 36 | "@types/turndown": "5.0.0", 37 | "@types/turndown-plugin-gfm": "file:./@types/turndown-plugin-gfm", 38 | "@typescript-eslint/parser": "^2.3.3", 39 | "copy-webpack-plugin": "^5.0.4", 40 | "eslint": "^6.5.1", 41 | "eslint-config-airbnb-typescript": "^5.0.0", 42 | "terser-webpack-plugin": "^2.1.2", 43 | "ts-loader": "^6.2.0", 44 | "ts-node": "^8.4.1", 45 | "typescript": "^3.7.2", 46 | "webpack": "^4.41.0", 47 | "webpack-cli": "^3.3.9" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/compass-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/content.css: -------------------------------------------------------------------------------- 1 | :root { 2 | overflow: hidden; 3 | } 4 | 5 | :host, 6 | :root { 7 | color-scheme: light dark; 8 | --bg-rgb: 222, 225, 230; 9 | --bg-color: rgb(var(--bg-rgb)); 10 | } 11 | 12 | @media (prefers-color-scheme: dark) { 13 | :host, 14 | :root { 15 | --bg-rgb: 53, 54, 58; 16 | } 17 | } 18 | 19 | :root { 20 | background-color: var(--bg-color); 21 | } 22 | 23 | body { 24 | transform: perspective(1000px) translate3d(0, 0, 0); 25 | transition: transform 0.2s ease; 26 | overflow: auto; 27 | max-width: 100vw; 28 | max-height: 100vh; 29 | } 30 | 31 | .ksp-browser-active body { 32 | transform: perspective(1000px) translate3d(0, 0, -300px); 33 | } 34 | 35 | .ksp-browser-annotation sup::before { 36 | content: '['; 37 | } 38 | .ksp-browser-annotation sup::after { 39 | content: ']'; 40 | } 41 | 42 | .ksp-browser-annotation, 43 | .ksp-browser-annotation .ksp-browser-siblinks { 44 | position: relative; 45 | margin: 0 !important; 46 | padding: 0 !important; 47 | text-decoration: none; 48 | border: none !important; 49 | } 50 | 51 | .ksp-browser-annotation { 52 | font-size: 80%; 53 | white-space: nowrap; 54 | font-weight: normal; 55 | font-style: normal; 56 | line-height: 1; 57 | direction: ltr; 58 | cursor: pointer; 59 | } 60 | 61 | .ksp-browser-annotation .ksp-browser-siblinks { 62 | font-family: sans-serif; 63 | cursor: pointer; 64 | } 65 | 66 | .ksp-browser-annotation .ksp-browser-siblinks:hover { 67 | text-decoration: underline; 68 | } 69 | -------------------------------------------------------------------------------- /public/disable-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/public/disable-icon-128.png -------------------------------------------------------------------------------- /public/double-dagger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/public/double-dagger.png -------------------------------------------------------------------------------- /public/double-dagger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 46 | 50 | -------------------------------------------------------------------------------- /public/file-alt-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/public/icon-128.png -------------------------------------------------------------------------------- /public/icon-128.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/public/icon-off.png -------------------------------------------------------------------------------- /public/icon-off.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon-on.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/public/icon-on.png -------------------------------------------------------------------------------- /public/link-solid.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "diesis", 3 | "description": "Add context to the web through the Knowledge Server Protocol (KSP).", 4 | "version": "0.1", 5 | "icons": { "144": "double-dagger.png" }, 6 | "permissions": ["activeTab", "contextMenus", "*://*/*"], 7 | "background": { 8 | "scripts": ["background.js"], 9 | "persistent": false 10 | }, 11 | "applications": { 12 | "gecko": { 13 | "id": "diesis@inkandswitch.com" 14 | } 15 | }, 16 | "content_scripts": [ 17 | { 18 | "matches": ["*://*/*"], 19 | "run_at": "document_start", 20 | "js": ["content.js"] 21 | } 22 | ], 23 | "web_accessible_resources": [ 24 | "ui.html", 25 | "ui.css", 26 | "content.css", 27 | "double-dagger.svg", 28 | "md-icon.svg", 29 | "website-icon.svg", 30 | "link-solid.svg", 31 | "file-alt-solid.svg" 32 | ], 33 | "browser_action": { 34 | "default_title": "Context", 35 | "default_icon": { 36 | "144": "double-dagger.png" 37 | } 38 | }, 39 | "commands": { 40 | "inspect-links": { 41 | "suggested_key": { 42 | "default": "Ctrl+Shift+I", 43 | "mac": "Command+Shift+I" 44 | }, 45 | "description": "Inspect links" 46 | }, 47 | "toggle-panel": { 48 | "suggested_key": { 49 | "default": "Ctrl+B", 50 | "mac": "Command+B" 51 | }, 52 | "description": "Toggles context plane" 53 | } 54 | }, 55 | "manifest_version": 2 56 | } 57 | -------------------------------------------------------------------------------- /public/md-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/tachyons.min.css: -------------------------------------------------------------------------------- 1 | /*! TACHYONS v4.11.2 | http://tachyons.io */ 2 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}.border-box,a,article,aside,blockquote,body,code,dd,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,html,input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=url],legend,li,main,nav,ol,p,pre,section,table,td,textarea,th,tr,ul{box-sizing:border-box}.aspect-ratio{height:0;position:relative}.aspect-ratio--16x9{padding-bottom:56.25%}.aspect-ratio--9x16{padding-bottom:177.77%}.aspect-ratio--4x3{padding-bottom:75%}.aspect-ratio--3x4{padding-bottom:133.33%}.aspect-ratio--6x4{padding-bottom:66.6%}.aspect-ratio--4x6{padding-bottom:150%}.aspect-ratio--8x5{padding-bottom:62.5%}.aspect-ratio--5x8{padding-bottom:160%}.aspect-ratio--7x5{padding-bottom:71.42%}.aspect-ratio--5x7{padding-bottom:140%}.aspect-ratio--1x1{padding-bottom:100%}.aspect-ratio--object{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}img{max-width:100%}.cover{background-size:cover!important}.contain{background-size:contain!important}.bg-center{background-position:50%}.bg-center,.bg-top{background-repeat:no-repeat}.bg-top{background-position:top}.bg-right{background-position:100%}.bg-bottom,.bg-right{background-repeat:no-repeat}.bg-bottom{background-position:bottom}.bg-left{background-repeat:no-repeat;background-position:0}.outline{outline:1px solid}.outline-transparent{outline:1px solid transparent}.outline-0{outline:0}.ba{border-style:solid;border-width:1px}.bt{border-top-style:solid;border-top-width:1px}.br{border-right-style:solid;border-right-width:1px}.bb{border-bottom-style:solid;border-bottom-width:1px}.bl{border-left-style:solid;border-left-width:1px}.bn{border-style:none;border-width:0}.b--black{border-color:#000}.b--near-black{border-color:#111}.b--dark-gray{border-color:#333}.b--mid-gray{border-color:#555}.b--gray{border-color:#777}.b--silver{border-color:#999}.b--light-silver{border-color:#aaa}.b--moon-gray{border-color:#ccc}.b--light-gray{border-color:#eee}.b--near-white{border-color:#f4f4f4}.b--white{border-color:#fff}.b--white-90{border-color:hsla(0,0%,100%,.9)}.b--white-80{border-color:hsla(0,0%,100%,.8)}.b--white-70{border-color:hsla(0,0%,100%,.7)}.b--white-60{border-color:hsla(0,0%,100%,.6)}.b--white-50{border-color:hsla(0,0%,100%,.5)}.b--white-40{border-color:hsla(0,0%,100%,.4)}.b--white-30{border-color:hsla(0,0%,100%,.3)}.b--white-20{border-color:hsla(0,0%,100%,.2)}.b--white-10{border-color:hsla(0,0%,100%,.1)}.b--white-05{border-color:hsla(0,0%,100%,.05)}.b--white-025{border-color:hsla(0,0%,100%,.025)}.b--white-0125{border-color:hsla(0,0%,100%,.0125)}.b--black-90{border-color:rgba(0,0,0,.9)}.b--black-80{border-color:rgba(0,0,0,.8)}.b--black-70{border-color:rgba(0,0,0,.7)}.b--black-60{border-color:rgba(0,0,0,.6)}.b--black-50{border-color:rgba(0,0,0,.5)}.b--black-40{border-color:rgba(0,0,0,.4)}.b--black-30{border-color:rgba(0,0,0,.3)}.b--black-20{border-color:rgba(0,0,0,.2)}.b--black-10{border-color:rgba(0,0,0,.1)}.b--black-05{border-color:rgba(0,0,0,.05)}.b--black-025{border-color:rgba(0,0,0,.025)}.b--black-0125{border-color:rgba(0,0,0,.0125)}.b--dark-red{border-color:#e7040f}.b--red{border-color:#ff4136}.b--light-red{border-color:#ff725c}.b--orange{border-color:#ff6300}.b--gold{border-color:#ffb700}.b--yellow{border-color:gold}.b--light-yellow{border-color:#fbf1a9}.b--purple{border-color:#5e2ca5}.b--light-purple{border-color:#a463f2}.b--dark-pink{border-color:#d5008f}.b--hot-pink{border-color:#ff41b4}.b--pink{border-color:#ff80cc}.b--light-pink{border-color:#ffa3d7}.b--dark-green{border-color:#137752}.b--green{border-color:#19a974}.b--light-green{border-color:#9eebcf}.b--navy{border-color:#001b44}.b--dark-blue{border-color:#00449e}.b--blue{border-color:#357edd}.b--light-blue{border-color:#96ccff}.b--lightest-blue{border-color:#cdecff}.b--washed-blue{border-color:#f6fffe}.b--washed-green{border-color:#e8fdf5}.b--washed-yellow{border-color:#fffceb}.b--washed-red{border-color:#ffdfdf}.b--transparent{border-color:transparent}.b--inherit{border-color:inherit}.br0{border-radius:0}.br1{border-radius:.125rem}.br2{border-radius:.25rem}.br3{border-radius:.5rem}.br4{border-radius:1rem}.br-100{border-radius:100%}.br-pill{border-radius:9999px}.br--bottom{border-top-left-radius:0;border-top-right-radius:0}.br--top{border-bottom-right-radius:0}.br--right,.br--top{border-bottom-left-radius:0}.br--right{border-top-left-radius:0}.br--left{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted{border-style:dotted}.b--dashed{border-style:dashed}.b--solid{border-style:solid}.b--none{border-style:none}.bw0{border-width:0}.bw1{border-width:.125rem}.bw2{border-width:.25rem}.bw3{border-width:.5rem}.bw4{border-width:1rem}.bw5{border-width:2rem}.bt-0{border-top-width:0}.br-0{border-right-width:0}.bb-0{border-bottom-width:0}.bl-0{border-left-width:0}.shadow-1{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.top-1{top:1rem}.right-1{right:1rem}.bottom-1{bottom:1rem}.left-1{left:1rem}.top-2{top:2rem}.right-2{right:2rem}.bottom-2{bottom:2rem}.left-2{left:2rem}.top--1{top:-1rem}.right--1{right:-1rem}.bottom--1{bottom:-1rem}.left--1{left:-1rem}.top--2{top:-2rem}.right--2{right:-2rem}.bottom--2{bottom:-2rem}.left--2{left:-2rem}.absolute--fill{top:0;right:0;bottom:0;left:0}.cf:after,.cf:before{content:" ";display:table}.cf:after{clear:both}.cf{*zoom:1}.cl{clear:left}.cr{clear:right}.cb{clear:both}.cn{clear:none}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dit{display:inline-table}.dt{display:table}.dtc{display:table-cell}.dt-row{display:table-row}.dt-row-group{display:table-row-group}.dt-column{display:table-column}.dt-column-group{display:table-column-group}.dt--fixed{table-layout:fixed;width:100%}.flex{display:flex}.inline-flex{display:inline-flex}.flex-auto{flex:1 1 auto;min-width:0;min-height:0}.flex-none{flex:none}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.flex-column-reverse{flex-direction:column-reverse}.flex-row-reverse{flex-direction:row-reverse}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.self-baseline{align-self:baseline}.self-stretch{align-self:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.content-start{align-content:flex-start}.content-end{align-content:flex-end}.content-center{align-content:center}.content-between{align-content:space-between}.content-around{align-content:space-around}.content-stretch{align-content:stretch}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-last{order:99999}.flex-grow-0{flex-grow:0}.flex-grow-1{flex-grow:1}.flex-shrink-0{flex-shrink:0}.flex-shrink-1{flex-shrink:1}.fl{float:left}.fl,.fr{_display:inline}.fr{float:right}.fn{float:none}.sans-serif{font-family:-apple-system,BlinkMacSystemFont,avenir next,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.serif{font-family:georgia,times,serif}.system-sans-serif{font-family:sans-serif}.system-serif{font-family:serif}.code,code{font-family:Consolas,monaco,monospace}.courier{font-family:Courier Next,courier,monospace}.helvetica{font-family:helvetica neue,helvetica,sans-serif}.avenir{font-family:avenir next,avenir,sans-serif}.athelas{font-family:athelas,georgia,serif}.georgia{font-family:georgia,serif}.times{font-family:times,serif}.bodoni{font-family:Bodoni MT,serif}.calisto{font-family:Calisto MT,serif}.garamond{font-family:garamond,serif}.baskerville{font-family:baskerville,serif}.i{font-style:italic}.fs-normal{font-style:normal}.normal{font-weight:400}.b{font-weight:700}.fw1{font-weight:100}.fw2{font-weight:200}.fw3{font-weight:300}.fw4{font-weight:400}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fw9{font-weight:900}.input-reset{-webkit-appearance:none;-moz-appearance:none}.button-reset::-moz-focus-inner,.input-reset::-moz-focus-inner{border:0;padding:0}.h1{height:1rem}.h2{height:2rem}.h3{height:4rem}.h4{height:8rem}.h5{height:16rem}.h-25{height:25%}.h-50{height:50%}.h-75{height:75%}.h-100{height:100%}.min-h-100{min-height:100%}.vh-25{height:25vh}.vh-50{height:50vh}.vh-75{height:75vh}.vh-100{height:100vh}.min-vh-100{min-height:100vh}.h-auto{height:auto}.h-inherit{height:inherit}.tracked{letter-spacing:.1em}.tracked-tight{letter-spacing:-.05em}.tracked-mega{letter-spacing:.25em}.lh-solid{line-height:1}.lh-title{line-height:1.25}.lh-copy{line-height:1.5}.link{text-decoration:none}.link,.link:active,.link:focus,.link:hover,.link:link,.link:visited{transition:color .15s ease-in}.link:focus{outline:1px dotted currentColor}.list{list-style-type:none}.mw-100{max-width:100%}.mw1{max-width:1rem}.mw2{max-width:2rem}.mw3{max-width:4rem}.mw4{max-width:8rem}.mw5{max-width:16rem}.mw6{max-width:32rem}.mw7{max-width:48rem}.mw8{max-width:64rem}.mw9{max-width:96rem}.mw-none{max-width:none}.w1{width:1rem}.w2{width:2rem}.w3{width:4rem}.w4{width:8rem}.w5{width:16rem}.w-10{width:10%}.w-20{width:20%}.w-25{width:25%}.w-30{width:30%}.w-33{width:33%}.w-34{width:34%}.w-40{width:40%}.w-50{width:50%}.w-60{width:60%}.w-70{width:70%}.w-75{width:75%}.w-80{width:80%}.w-90{width:90%}.w-100{width:100%}.w-third{width:33.33333%}.w-two-thirds{width:66.66667%}.w-auto{width:auto}.overflow-visible{overflow:visible}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-auto{overflow:auto}.overflow-x-visible{overflow-x:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-visible{overflow-y:visible}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-auto{overflow-y:auto}.static{position:static}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.o-100{opacity:1}.o-90{opacity:.9}.o-80{opacity:.8}.o-70{opacity:.7}.o-60{opacity:.6}.o-50{opacity:.5}.o-40{opacity:.4}.o-30{opacity:.3}.o-20{opacity:.2}.o-10{opacity:.1}.o-05{opacity:.05}.o-025{opacity:.025}.o-0{opacity:0}.rotate-45{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.black-90{color:rgba(0,0,0,.9)}.black-80{color:rgba(0,0,0,.8)}.black-70{color:rgba(0,0,0,.7)}.black-60{color:rgba(0,0,0,.6)}.black-50{color:rgba(0,0,0,.5)}.black-40{color:rgba(0,0,0,.4)}.black-30{color:rgba(0,0,0,.3)}.black-20{color:rgba(0,0,0,.2)}.black-10{color:rgba(0,0,0,.1)}.black-05{color:rgba(0,0,0,.05)}.white-90{color:hsla(0,0%,100%,.9)}.white-80{color:hsla(0,0%,100%,.8)}.white-70{color:hsla(0,0%,100%,.7)}.white-60{color:hsla(0,0%,100%,.6)}.white-50{color:hsla(0,0%,100%,.5)}.white-40{color:hsla(0,0%,100%,.4)}.white-30{color:hsla(0,0%,100%,.3)}.white-20{color:hsla(0,0%,100%,.2)}.white-10{color:hsla(0,0%,100%,.1)}.black{color:#000}.near-black{color:#111}.dark-gray{color:#333}.mid-gray{color:#555}.gray{color:#777}.silver{color:#999}.light-silver{color:#aaa}.moon-gray{color:#ccc}.light-gray{color:#eee}.near-white{color:#f4f4f4}.white{color:#fff}.dark-red{color:#e7040f}.red{color:#ff4136}.light-red{color:#ff725c}.orange{color:#ff6300}.gold{color:#ffb700}.yellow{color:gold}.light-yellow{color:#fbf1a9}.purple{color:#5e2ca5}.light-purple{color:#a463f2}.dark-pink{color:#d5008f}.hot-pink{color:#ff41b4}.pink{color:#ff80cc}.light-pink{color:#ffa3d7}.dark-green{color:#137752}.green{color:#19a974}.light-green{color:#9eebcf}.navy{color:#001b44}.dark-blue{color:#00449e}.blue{color:#357edd}.light-blue{color:#96ccff}.lightest-blue{color:#cdecff}.washed-blue{color:#f6fffe}.washed-green{color:#e8fdf5}.washed-yellow{color:#fffceb}.washed-red{color:#ffdfdf}.color-inherit{color:inherit}.bg-black-90{background-color:rgba(0,0,0,.9)}.bg-black-80{background-color:rgba(0,0,0,.8)}.bg-black-70{background-color:rgba(0,0,0,.7)}.bg-black-60{background-color:rgba(0,0,0,.6)}.bg-black-50{background-color:rgba(0,0,0,.5)}.bg-black-40{background-color:rgba(0,0,0,.4)}.bg-black-30{background-color:rgba(0,0,0,.3)}.bg-black-20{background-color:rgba(0,0,0,.2)}.bg-black-10{background-color:rgba(0,0,0,.1)}.bg-black-05{background-color:rgba(0,0,0,.05)}.bg-white-90{background-color:hsla(0,0%,100%,.9)}.bg-white-80{background-color:hsla(0,0%,100%,.8)}.bg-white-70{background-color:hsla(0,0%,100%,.7)}.bg-white-60{background-color:hsla(0,0%,100%,.6)}.bg-white-50{background-color:hsla(0,0%,100%,.5)}.bg-white-40{background-color:hsla(0,0%,100%,.4)}.bg-white-30{background-color:hsla(0,0%,100%,.3)}.bg-white-20{background-color:hsla(0,0%,100%,.2)}.bg-white-10{background-color:hsla(0,0%,100%,.1)}.bg-black{background-color:#000}.bg-near-black{background-color:#111}.bg-dark-gray{background-color:#333}.bg-mid-gray{background-color:#555}.bg-gray{background-color:#777}.bg-silver{background-color:#999}.bg-light-silver{background-color:#aaa}.bg-moon-gray{background-color:#ccc}.bg-light-gray{background-color:#eee}.bg-near-white{background-color:#f4f4f4}.bg-white{background-color:#fff}.bg-transparent{background-color:transparent}.bg-dark-red{background-color:#e7040f}.bg-red{background-color:#ff4136}.bg-light-red{background-color:#ff725c}.bg-orange{background-color:#ff6300}.bg-gold{background-color:#ffb700}.bg-yellow{background-color:gold}.bg-light-yellow{background-color:#fbf1a9}.bg-purple{background-color:#5e2ca5}.bg-light-purple{background-color:#a463f2}.bg-dark-pink{background-color:#d5008f}.bg-hot-pink{background-color:#ff41b4}.bg-pink{background-color:#ff80cc}.bg-light-pink{background-color:#ffa3d7}.bg-dark-green{background-color:#137752}.bg-green{background-color:#19a974}.bg-light-green{background-color:#9eebcf}.bg-navy{background-color:#001b44}.bg-dark-blue{background-color:#00449e}.bg-blue{background-color:#357edd}.bg-light-blue{background-color:#96ccff}.bg-lightest-blue{background-color:#cdecff}.bg-washed-blue{background-color:#f6fffe}.bg-washed-green{background-color:#e8fdf5}.bg-washed-yellow{background-color:#fffceb}.bg-washed-red{background-color:#ffdfdf}.bg-inherit{background-color:inherit}.hover-black:focus,.hover-black:hover{color:#000}.hover-near-black:focus,.hover-near-black:hover{color:#111}.hover-dark-gray:focus,.hover-dark-gray:hover{color:#333}.hover-mid-gray:focus,.hover-mid-gray:hover{color:#555}.hover-gray:focus,.hover-gray:hover{color:#777}.hover-silver:focus,.hover-silver:hover{color:#999}.hover-light-silver:focus,.hover-light-silver:hover{color:#aaa}.hover-moon-gray:focus,.hover-moon-gray:hover{color:#ccc}.hover-light-gray:focus,.hover-light-gray:hover{color:#eee}.hover-near-white:focus,.hover-near-white:hover{color:#f4f4f4}.hover-white:focus,.hover-white:hover{color:#fff}.hover-black-90:focus,.hover-black-90:hover{color:rgba(0,0,0,.9)}.hover-black-80:focus,.hover-black-80:hover{color:rgba(0,0,0,.8)}.hover-black-70:focus,.hover-black-70:hover{color:rgba(0,0,0,.7)}.hover-black-60:focus,.hover-black-60:hover{color:rgba(0,0,0,.6)}.hover-black-50:focus,.hover-black-50:hover{color:rgba(0,0,0,.5)}.hover-black-40:focus,.hover-black-40:hover{color:rgba(0,0,0,.4)}.hover-black-30:focus,.hover-black-30:hover{color:rgba(0,0,0,.3)}.hover-black-20:focus,.hover-black-20:hover{color:rgba(0,0,0,.2)}.hover-black-10:focus,.hover-black-10:hover{color:rgba(0,0,0,.1)}.hover-white-90:focus,.hover-white-90:hover{color:hsla(0,0%,100%,.9)}.hover-white-80:focus,.hover-white-80:hover{color:hsla(0,0%,100%,.8)}.hover-white-70:focus,.hover-white-70:hover{color:hsla(0,0%,100%,.7)}.hover-white-60:focus,.hover-white-60:hover{color:hsla(0,0%,100%,.6)}.hover-white-50:focus,.hover-white-50:hover{color:hsla(0,0%,100%,.5)}.hover-white-40:focus,.hover-white-40:hover{color:hsla(0,0%,100%,.4)}.hover-white-30:focus,.hover-white-30:hover{color:hsla(0,0%,100%,.3)}.hover-white-20:focus,.hover-white-20:hover{color:hsla(0,0%,100%,.2)}.hover-white-10:focus,.hover-white-10:hover{color:hsla(0,0%,100%,.1)}.hover-inherit:focus,.hover-inherit:hover{color:inherit}.hover-bg-black:focus,.hover-bg-black:hover{background-color:#000}.hover-bg-near-black:focus,.hover-bg-near-black:hover{background-color:#111}.hover-bg-dark-gray:focus,.hover-bg-dark-gray:hover{background-color:#333}.hover-bg-mid-gray:focus,.hover-bg-mid-gray:hover{background-color:#555}.hover-bg-gray:focus,.hover-bg-gray:hover{background-color:#777}.hover-bg-silver:focus,.hover-bg-silver:hover{background-color:#999}.hover-bg-light-silver:focus,.hover-bg-light-silver:hover{background-color:#aaa}.hover-bg-moon-gray:focus,.hover-bg-moon-gray:hover{background-color:#ccc}.hover-bg-light-gray:focus,.hover-bg-light-gray:hover{background-color:#eee}.hover-bg-near-white:focus,.hover-bg-near-white:hover{background-color:#f4f4f4}.hover-bg-white:focus,.hover-bg-white:hover{background-color:#fff}.hover-bg-transparent:focus,.hover-bg-transparent:hover{background-color:transparent}.hover-bg-black-90:focus,.hover-bg-black-90:hover{background-color:rgba(0,0,0,.9)}.hover-bg-black-80:focus,.hover-bg-black-80:hover{background-color:rgba(0,0,0,.8)}.hover-bg-black-70:focus,.hover-bg-black-70:hover{background-color:rgba(0,0,0,.7)}.hover-bg-black-60:focus,.hover-bg-black-60:hover{background-color:rgba(0,0,0,.6)}.hover-bg-black-50:focus,.hover-bg-black-50:hover{background-color:rgba(0,0,0,.5)}.hover-bg-black-40:focus,.hover-bg-black-40:hover{background-color:rgba(0,0,0,.4)}.hover-bg-black-30:focus,.hover-bg-black-30:hover{background-color:rgba(0,0,0,.3)}.hover-bg-black-20:focus,.hover-bg-black-20:hover{background-color:rgba(0,0,0,.2)}.hover-bg-black-10:focus,.hover-bg-black-10:hover{background-color:rgba(0,0,0,.1)}.hover-bg-white-90:focus,.hover-bg-white-90:hover{background-color:hsla(0,0%,100%,.9)}.hover-bg-white-80:focus,.hover-bg-white-80:hover{background-color:hsla(0,0%,100%,.8)}.hover-bg-white-70:focus,.hover-bg-white-70:hover{background-color:hsla(0,0%,100%,.7)}.hover-bg-white-60:focus,.hover-bg-white-60:hover{background-color:hsla(0,0%,100%,.6)}.hover-bg-white-50:focus,.hover-bg-white-50:hover{background-color:hsla(0,0%,100%,.5)}.hover-bg-white-40:focus,.hover-bg-white-40:hover{background-color:hsla(0,0%,100%,.4)}.hover-bg-white-30:focus,.hover-bg-white-30:hover{background-color:hsla(0,0%,100%,.3)}.hover-bg-white-20:focus,.hover-bg-white-20:hover{background-color:hsla(0,0%,100%,.2)}.hover-bg-white-10:focus,.hover-bg-white-10:hover{background-color:hsla(0,0%,100%,.1)}.hover-dark-red:focus,.hover-dark-red:hover{color:#e7040f}.hover-red:focus,.hover-red:hover{color:#ff4136}.hover-light-red:focus,.hover-light-red:hover{color:#ff725c}.hover-orange:focus,.hover-orange:hover{color:#ff6300}.hover-gold:focus,.hover-gold:hover{color:#ffb700}.hover-yellow:focus,.hover-yellow:hover{color:gold}.hover-light-yellow:focus,.hover-light-yellow:hover{color:#fbf1a9}.hover-purple:focus,.hover-purple:hover{color:#5e2ca5}.hover-light-purple:focus,.hover-light-purple:hover{color:#a463f2}.hover-dark-pink:focus,.hover-dark-pink:hover{color:#d5008f}.hover-hot-pink:focus,.hover-hot-pink:hover{color:#ff41b4}.hover-pink:focus,.hover-pink:hover{color:#ff80cc}.hover-light-pink:focus,.hover-light-pink:hover{color:#ffa3d7}.hover-dark-green:focus,.hover-dark-green:hover{color:#137752}.hover-green:focus,.hover-green:hover{color:#19a974}.hover-light-green:focus,.hover-light-green:hover{color:#9eebcf}.hover-navy:focus,.hover-navy:hover{color:#001b44}.hover-dark-blue:focus,.hover-dark-blue:hover{color:#00449e}.hover-blue:focus,.hover-blue:hover{color:#357edd}.hover-light-blue:focus,.hover-light-blue:hover{color:#96ccff}.hover-lightest-blue:focus,.hover-lightest-blue:hover{color:#cdecff}.hover-washed-blue:focus,.hover-washed-blue:hover{color:#f6fffe}.hover-washed-green:focus,.hover-washed-green:hover{color:#e8fdf5}.hover-washed-yellow:focus,.hover-washed-yellow:hover{color:#fffceb}.hover-washed-red:focus,.hover-washed-red:hover{color:#ffdfdf}.hover-bg-dark-red:focus,.hover-bg-dark-red:hover{background-color:#e7040f}.hover-bg-red:focus,.hover-bg-red:hover{background-color:#ff4136}.hover-bg-light-red:focus,.hover-bg-light-red:hover{background-color:#ff725c}.hover-bg-orange:focus,.hover-bg-orange:hover{background-color:#ff6300}.hover-bg-gold:focus,.hover-bg-gold:hover{background-color:#ffb700}.hover-bg-yellow:focus,.hover-bg-yellow:hover{background-color:gold}.hover-bg-light-yellow:focus,.hover-bg-light-yellow:hover{background-color:#fbf1a9}.hover-bg-purple:focus,.hover-bg-purple:hover{background-color:#5e2ca5}.hover-bg-light-purple:focus,.hover-bg-light-purple:hover{background-color:#a463f2}.hover-bg-dark-pink:focus,.hover-bg-dark-pink:hover{background-color:#d5008f}.hover-bg-hot-pink:focus,.hover-bg-hot-pink:hover{background-color:#ff41b4}.hover-bg-pink:focus,.hover-bg-pink:hover{background-color:#ff80cc}.hover-bg-light-pink:focus,.hover-bg-light-pink:hover{background-color:#ffa3d7}.hover-bg-dark-green:focus,.hover-bg-dark-green:hover{background-color:#137752}.hover-bg-green:focus,.hover-bg-green:hover{background-color:#19a974}.hover-bg-light-green:focus,.hover-bg-light-green:hover{background-color:#9eebcf}.hover-bg-navy:focus,.hover-bg-navy:hover{background-color:#001b44}.hover-bg-dark-blue:focus,.hover-bg-dark-blue:hover{background-color:#00449e}.hover-bg-blue:focus,.hover-bg-blue:hover{background-color:#357edd}.hover-bg-light-blue:focus,.hover-bg-light-blue:hover{background-color:#96ccff}.hover-bg-lightest-blue:focus,.hover-bg-lightest-blue:hover{background-color:#cdecff}.hover-bg-washed-blue:focus,.hover-bg-washed-blue:hover{background-color:#f6fffe}.hover-bg-washed-green:focus,.hover-bg-washed-green:hover{background-color:#e8fdf5}.hover-bg-washed-yellow:focus,.hover-bg-washed-yellow:hover{background-color:#fffceb}.hover-bg-washed-red:focus,.hover-bg-washed-red:hover{background-color:#ffdfdf}.hover-bg-inherit:focus,.hover-bg-inherit:hover{background-color:inherit}.pa0{padding:0}.pa1{padding:.25rem}.pa2{padding:.5rem}.pa3{padding:1rem}.pa4{padding:2rem}.pa5{padding:4rem}.pa6{padding:8rem}.pa7{padding:16rem}.pl0{padding-left:0}.pl1{padding-left:.25rem}.pl2{padding-left:.5rem}.pl3{padding-left:1rem}.pl4{padding-left:2rem}.pl5{padding-left:4rem}.pl6{padding-left:8rem}.pl7{padding-left:16rem}.pr0{padding-right:0}.pr1{padding-right:.25rem}.pr2{padding-right:.5rem}.pr3{padding-right:1rem}.pr4{padding-right:2rem}.pr5{padding-right:4rem}.pr6{padding-right:8rem}.pr7{padding-right:16rem}.pb0{padding-bottom:0}.pb1{padding-bottom:.25rem}.pb2{padding-bottom:.5rem}.pb3{padding-bottom:1rem}.pb4{padding-bottom:2rem}.pb5{padding-bottom:4rem}.pb6{padding-bottom:8rem}.pb7{padding-bottom:16rem}.pt0{padding-top:0}.pt1{padding-top:.25rem}.pt2{padding-top:.5rem}.pt3{padding-top:1rem}.pt4{padding-top:2rem}.pt5{padding-top:4rem}.pt6{padding-top:8rem}.pt7{padding-top:16rem}.pv0{padding-top:0;padding-bottom:0}.pv1{padding-top:.25rem;padding-bottom:.25rem}.pv2{padding-top:.5rem;padding-bottom:.5rem}.pv3{padding-top:1rem;padding-bottom:1rem}.pv4{padding-top:2rem;padding-bottom:2rem}.pv5{padding-top:4rem;padding-bottom:4rem}.pv6{padding-top:8rem;padding-bottom:8rem}.pv7{padding-top:16rem;padding-bottom:16rem}.ph0{padding-left:0;padding-right:0}.ph1{padding-left:.25rem;padding-right:.25rem}.ph2{padding-left:.5rem;padding-right:.5rem}.ph3{padding-left:1rem;padding-right:1rem}.ph4{padding-left:2rem;padding-right:2rem}.ph5{padding-left:4rem;padding-right:4rem}.ph6{padding-left:8rem;padding-right:8rem}.ph7{padding-left:16rem;padding-right:16rem}.ma0{margin:0}.ma1{margin:.25rem}.ma2{margin:.5rem}.ma3{margin:1rem}.ma4{margin:2rem}.ma5{margin:4rem}.ma6{margin:8rem}.ma7{margin:16rem}.ml0{margin-left:0}.ml1{margin-left:.25rem}.ml2{margin-left:.5rem}.ml3{margin-left:1rem}.ml4{margin-left:2rem}.ml5{margin-left:4rem}.ml6{margin-left:8rem}.ml7{margin-left:16rem}.mr0{margin-right:0}.mr1{margin-right:.25rem}.mr2{margin-right:.5rem}.mr3{margin-right:1rem}.mr4{margin-right:2rem}.mr5{margin-right:4rem}.mr6{margin-right:8rem}.mr7{margin-right:16rem}.mb0{margin-bottom:0}.mb1{margin-bottom:.25rem}.mb2{margin-bottom:.5rem}.mb3{margin-bottom:1rem}.mb4{margin-bottom:2rem}.mb5{margin-bottom:4rem}.mb6{margin-bottom:8rem}.mb7{margin-bottom:16rem}.mt0{margin-top:0}.mt1{margin-top:.25rem}.mt2{margin-top:.5rem}.mt3{margin-top:1rem}.mt4{margin-top:2rem}.mt5{margin-top:4rem}.mt6{margin-top:8rem}.mt7{margin-top:16rem}.mv0{margin-top:0;margin-bottom:0}.mv1{margin-top:.25rem;margin-bottom:.25rem}.mv2{margin-top:.5rem;margin-bottom:.5rem}.mv3{margin-top:1rem;margin-bottom:1rem}.mv4{margin-top:2rem;margin-bottom:2rem}.mv5{margin-top:4rem;margin-bottom:4rem}.mv6{margin-top:8rem;margin-bottom:8rem}.mv7{margin-top:16rem;margin-bottom:16rem}.mh0{margin-left:0;margin-right:0}.mh1{margin-left:.25rem;margin-right:.25rem}.mh2{margin-left:.5rem;margin-right:.5rem}.mh3{margin-left:1rem;margin-right:1rem}.mh4{margin-left:2rem;margin-right:2rem}.mh5{margin-left:4rem;margin-right:4rem}.mh6{margin-left:8rem;margin-right:8rem}.mh7{margin-left:16rem;margin-right:16rem}.na1{margin:-.25rem}.na2{margin:-.5rem}.na3{margin:-1rem}.na4{margin:-2rem}.na5{margin:-4rem}.na6{margin:-8rem}.na7{margin:-16rem}.nl1{margin-left:-.25rem}.nl2{margin-left:-.5rem}.nl3{margin-left:-1rem}.nl4{margin-left:-2rem}.nl5{margin-left:-4rem}.nl6{margin-left:-8rem}.nl7{margin-left:-16rem}.nr1{margin-right:-.25rem}.nr2{margin-right:-.5rem}.nr3{margin-right:-1rem}.nr4{margin-right:-2rem}.nr5{margin-right:-4rem}.nr6{margin-right:-8rem}.nr7{margin-right:-16rem}.nb1{margin-bottom:-.25rem}.nb2{margin-bottom:-.5rem}.nb3{margin-bottom:-1rem}.nb4{margin-bottom:-2rem}.nb5{margin-bottom:-4rem}.nb6{margin-bottom:-8rem}.nb7{margin-bottom:-16rem}.nt1{margin-top:-.25rem}.nt2{margin-top:-.5rem}.nt3{margin-top:-1rem}.nt4{margin-top:-2rem}.nt5{margin-top:-4rem}.nt6{margin-top:-8rem}.nt7{margin-top:-16rem}.collapse{border-collapse:collapse;border-spacing:0}.striped--light-silver:nth-child(odd){background-color:#aaa}.striped--moon-gray:nth-child(odd){background-color:#ccc}.striped--light-gray:nth-child(odd){background-color:#eee}.striped--near-white:nth-child(odd){background-color:#f4f4f4}.stripe-light:nth-child(odd){background-color:hsla(0,0%,100%,.1)}.stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.strike{text-decoration:line-through}.underline{text-decoration:underline}.no-underline{text-decoration:none}.tl{text-align:left}.tr{text-align:right}.tc{text-align:center}.tj{text-align:justify}.ttc{text-transform:capitalize}.ttl{text-transform:lowercase}.ttu{text-transform:uppercase}.ttn{text-transform:none}.f-6,.f-headline{font-size:6rem}.f-5,.f-subheadline{font-size:5rem}.f1{font-size:3rem}.f2{font-size:2.25rem}.f3{font-size:1.5rem}.f4{font-size:1.25rem}.f5{font-size:1rem}.f6{font-size:.875rem}.f7{font-size:.75rem}.measure{max-width:30em}.measure-wide{max-width:34em}.measure-narrow{max-width:20em}.indent{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps{font-variant:small-caps}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.overflow-container{overflow-y:scroll}.center{margin-left:auto}.center,.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}.clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal{white-space:normal}.nowrap{white-space:nowrap}.pre{white-space:pre}.v-base{vertical-align:baseline}.v-mid{vertical-align:middle}.v-top{vertical-align:top}.v-btm{vertical-align:bottom}.dim{opacity:1}.dim,.dim:focus,.dim:hover{transition:opacity .15s ease-in}.dim:focus,.dim:hover{opacity:.5}.dim:active{opacity:.8;transition:opacity .15s ease-out}.glow,.glow:focus,.glow:hover{transition:opacity .15s ease-in}.glow:focus,.glow:hover{opacity:1}.hide-child .child{opacity:0;transition:opacity .15s ease-in}.hide-child:active .child,.hide-child:focus .child,.hide-child:hover .child{opacity:1;transition:opacity .15s ease-in}.underline-hover:focus,.underline-hover:hover{text-decoration:underline}.grow{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-out;transition:transform .25s ease-out;transition:transform .25s ease-out,-webkit-transform .25s ease-out}.grow:focus,.grow:hover{-webkit-transform:scale(1.05);transform:scale(1.05)}.grow:active{-webkit-transform:scale(.9);transform:scale(.9)}.grow-large{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-in-out;transition:transform .25s ease-in-out;transition:transform .25s ease-in-out,-webkit-transform .25s ease-in-out}.grow-large:focus,.grow-large:hover{-webkit-transform:scale(1.2);transform:scale(1.2)}.grow-large:active{-webkit-transform:scale(.95);transform:scale(.95)}.pointer:hover,.shadow-hover{cursor:pointer}.shadow-hover{position:relative;transition:all .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:after{content:"";box-shadow:0 0 16px 2px rgba(0,0,0,.2);border-radius:inherit;opacity:0;position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;transition:opacity .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:focus:after,.shadow-hover:hover:after{opacity:1}.bg-animate,.bg-animate:focus,.bg-animate:hover{transition:background-color .15s ease-in-out}.z-0{z-index:0}.z-1{z-index:1}.z-2{z-index:2}.z-3{z-index:3}.z-4{z-index:4}.z-5{z-index:5}.z-999{z-index:999}.z-9999{z-index:9999}.z-max{z-index:2147483647}.z-inherit{z-index:inherit}.z-initial{z-index:auto}.z-unset{z-index:unset}.nested-copy-line-height ol,.nested-copy-line-height p,.nested-copy-line-height ul{line-height:1.5}.nested-headline-line-height h1,.nested-headline-line-height h2,.nested-headline-line-height h3,.nested-headline-line-height h4,.nested-headline-line-height h5,.nested-headline-line-height h6{line-height:1.25}.nested-list-reset ol,.nested-list-reset ul{padding-left:0;margin-left:0;list-style-type:none}.nested-copy-indent p+p{text-indent:1em;margin-top:0;margin-bottom:0}.nested-copy-separator p+p{margin-top:1.5em}.nested-img img{width:100%;max-width:100%;display:block}.nested-links a{color:#357edd;transition:color .15s ease-in}.nested-links a:focus,.nested-links a:hover{color:#96ccff;transition:color .15s ease-in}.debug *{outline:1px solid gold}.debug-white *{outline:1px solid #fff}.debug-black *{outline:1px solid #000}.debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4AWPAC97/9x0eCsAEPgwAVLshdpENIxcAAAAASUVORK5CYII=) repeat 0 0}.debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMklEQVR4AWOgCLz/b0epAa6UGuBOqQHOQHLUgFEDnAbcBZ4UGwDOkiCnkIhdgNgNxAYAiYlD+8sEuo8AAAAASUVORK5CYII=) repeat 0 0}.debug-grid-8-solid{background:#fff url(data:image/gif;base64,R0lGODdhCAAIAPEAAADw/wDx/////wAAACwAAAAACAAIAAACDZQvgaeb/lxbAIKA8y0AOw==) repeat 0 0}.debug-grid-16-solid{background:#fff url(data:image/gif;base64,R0lGODdhEAAQAPEAAADw/wDx/xXy/////ywAAAAAEAAQAAACIZyPKckYDQFsb6ZqD85jZ2+BkwiRFKehhqQCQgDHcgwEBQA7) repeat 0 0}@media screen and (min-width:30em){.aspect-ratio-ns{height:0;position:relative}.aspect-ratio--16x9-ns{padding-bottom:56.25%}.aspect-ratio--9x16-ns{padding-bottom:177.77%}.aspect-ratio--4x3-ns{padding-bottom:75%}.aspect-ratio--3x4-ns{padding-bottom:133.33%}.aspect-ratio--6x4-ns{padding-bottom:66.6%}.aspect-ratio--4x6-ns{padding-bottom:150%}.aspect-ratio--8x5-ns{padding-bottom:62.5%}.aspect-ratio--5x8-ns{padding-bottom:160%}.aspect-ratio--7x5-ns{padding-bottom:71.42%}.aspect-ratio--5x7-ns{padding-bottom:140%}.aspect-ratio--1x1-ns{padding-bottom:100%}.aspect-ratio--object-ns{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-ns{background-size:cover!important}.contain-ns{background-size:contain!important}.bg-center-ns{background-position:50%}.bg-center-ns,.bg-top-ns{background-repeat:no-repeat}.bg-top-ns{background-position:top}.bg-right-ns{background-position:100%}.bg-bottom-ns,.bg-right-ns{background-repeat:no-repeat}.bg-bottom-ns{background-position:bottom}.bg-left-ns{background-repeat:no-repeat;background-position:0}.outline-ns{outline:1px solid}.outline-transparent-ns{outline:1px solid transparent}.outline-0-ns{outline:0}.ba-ns{border-style:solid;border-width:1px}.bt-ns{border-top-style:solid;border-top-width:1px}.br-ns{border-right-style:solid;border-right-width:1px}.bb-ns{border-bottom-style:solid;border-bottom-width:1px}.bl-ns{border-left-style:solid;border-left-width:1px}.bn-ns{border-style:none;border-width:0}.br0-ns{border-radius:0}.br1-ns{border-radius:.125rem}.br2-ns{border-radius:.25rem}.br3-ns{border-radius:.5rem}.br4-ns{border-radius:1rem}.br-100-ns{border-radius:100%}.br-pill-ns{border-radius:9999px}.br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.br--top-ns{border-bottom-right-radius:0}.br--right-ns,.br--top-ns{border-bottom-left-radius:0}.br--right-ns{border-top-left-radius:0}.br--left-ns{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-ns{border-style:dotted}.b--dashed-ns{border-style:dashed}.b--solid-ns{border-style:solid}.b--none-ns{border-style:none}.bw0-ns{border-width:0}.bw1-ns{border-width:.125rem}.bw2-ns{border-width:.25rem}.bw3-ns{border-width:.5rem}.bw4-ns{border-width:1rem}.bw5-ns{border-width:2rem}.bt-0-ns{border-top-width:0}.br-0-ns{border-right-width:0}.bb-0-ns{border-bottom-width:0}.bl-0-ns{border-left-width:0}.shadow-1-ns{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-ns{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-ns{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-ns{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-ns{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-ns{top:0}.left-0-ns{left:0}.right-0-ns{right:0}.bottom-0-ns{bottom:0}.top-1-ns{top:1rem}.left-1-ns{left:1rem}.right-1-ns{right:1rem}.bottom-1-ns{bottom:1rem}.top-2-ns{top:2rem}.left-2-ns{left:2rem}.right-2-ns{right:2rem}.bottom-2-ns{bottom:2rem}.top--1-ns{top:-1rem}.right--1-ns{right:-1rem}.bottom--1-ns{bottom:-1rem}.left--1-ns{left:-1rem}.top--2-ns{top:-2rem}.right--2-ns{right:-2rem}.bottom--2-ns{bottom:-2rem}.left--2-ns{left:-2rem}.absolute--fill-ns{top:0;right:0;bottom:0;left:0}.cl-ns{clear:left}.cr-ns{clear:right}.cb-ns{clear:both}.cn-ns{clear:none}.dn-ns{display:none}.di-ns{display:inline}.db-ns{display:block}.dib-ns{display:inline-block}.dit-ns{display:inline-table}.dt-ns{display:table}.dtc-ns{display:table-cell}.dt-row-ns{display:table-row}.dt-row-group-ns{display:table-row-group}.dt-column-ns{display:table-column}.dt-column-group-ns{display:table-column-group}.dt--fixed-ns{table-layout:fixed;width:100%}.flex-ns{display:flex}.inline-flex-ns{display:inline-flex}.flex-auto-ns{flex:1 1 auto;min-width:0;min-height:0}.flex-none-ns{flex:none}.flex-column-ns{flex-direction:column}.flex-row-ns{flex-direction:row}.flex-wrap-ns{flex-wrap:wrap}.flex-nowrap-ns{flex-wrap:nowrap}.flex-wrap-reverse-ns{flex-wrap:wrap-reverse}.flex-column-reverse-ns{flex-direction:column-reverse}.flex-row-reverse-ns{flex-direction:row-reverse}.items-start-ns{align-items:flex-start}.items-end-ns{align-items:flex-end}.items-center-ns{align-items:center}.items-baseline-ns{align-items:baseline}.items-stretch-ns{align-items:stretch}.self-start-ns{align-self:flex-start}.self-end-ns{align-self:flex-end}.self-center-ns{align-self:center}.self-baseline-ns{align-self:baseline}.self-stretch-ns{align-self:stretch}.justify-start-ns{justify-content:flex-start}.justify-end-ns{justify-content:flex-end}.justify-center-ns{justify-content:center}.justify-between-ns{justify-content:space-between}.justify-around-ns{justify-content:space-around}.content-start-ns{align-content:flex-start}.content-end-ns{align-content:flex-end}.content-center-ns{align-content:center}.content-between-ns{align-content:space-between}.content-around-ns{align-content:space-around}.content-stretch-ns{align-content:stretch}.order-0-ns{order:0}.order-1-ns{order:1}.order-2-ns{order:2}.order-3-ns{order:3}.order-4-ns{order:4}.order-5-ns{order:5}.order-6-ns{order:6}.order-7-ns{order:7}.order-8-ns{order:8}.order-last-ns{order:99999}.flex-grow-0-ns{flex-grow:0}.flex-grow-1-ns{flex-grow:1}.flex-shrink-0-ns{flex-shrink:0}.flex-shrink-1-ns{flex-shrink:1}.fl-ns{float:left}.fl-ns,.fr-ns{_display:inline}.fr-ns{float:right}.fn-ns{float:none}.i-ns{font-style:italic}.fs-normal-ns{font-style:normal}.normal-ns{font-weight:400}.b-ns{font-weight:700}.fw1-ns{font-weight:100}.fw2-ns{font-weight:200}.fw3-ns{font-weight:300}.fw4-ns{font-weight:400}.fw5-ns{font-weight:500}.fw6-ns{font-weight:600}.fw7-ns{font-weight:700}.fw8-ns{font-weight:800}.fw9-ns{font-weight:900}.h1-ns{height:1rem}.h2-ns{height:2rem}.h3-ns{height:4rem}.h4-ns{height:8rem}.h5-ns{height:16rem}.h-25-ns{height:25%}.h-50-ns{height:50%}.h-75-ns{height:75%}.h-100-ns{height:100%}.min-h-100-ns{min-height:100%}.vh-25-ns{height:25vh}.vh-50-ns{height:50vh}.vh-75-ns{height:75vh}.vh-100-ns{height:100vh}.min-vh-100-ns{min-height:100vh}.h-auto-ns{height:auto}.h-inherit-ns{height:inherit}.tracked-ns{letter-spacing:.1em}.tracked-tight-ns{letter-spacing:-.05em}.tracked-mega-ns{letter-spacing:.25em}.lh-solid-ns{line-height:1}.lh-title-ns{line-height:1.25}.lh-copy-ns{line-height:1.5}.mw-100-ns{max-width:100%}.mw1-ns{max-width:1rem}.mw2-ns{max-width:2rem}.mw3-ns{max-width:4rem}.mw4-ns{max-width:8rem}.mw5-ns{max-width:16rem}.mw6-ns{max-width:32rem}.mw7-ns{max-width:48rem}.mw8-ns{max-width:64rem}.mw9-ns{max-width:96rem}.mw-none-ns{max-width:none}.w1-ns{width:1rem}.w2-ns{width:2rem}.w3-ns{width:4rem}.w4-ns{width:8rem}.w5-ns{width:16rem}.w-10-ns{width:10%}.w-20-ns{width:20%}.w-25-ns{width:25%}.w-30-ns{width:30%}.w-33-ns{width:33%}.w-34-ns{width:34%}.w-40-ns{width:40%}.w-50-ns{width:50%}.w-60-ns{width:60%}.w-70-ns{width:70%}.w-75-ns{width:75%}.w-80-ns{width:80%}.w-90-ns{width:90%}.w-100-ns{width:100%}.w-third-ns{width:33.33333%}.w-two-thirds-ns{width:66.66667%}.w-auto-ns{width:auto}.overflow-visible-ns{overflow:visible}.overflow-hidden-ns{overflow:hidden}.overflow-scroll-ns{overflow:scroll}.overflow-auto-ns{overflow:auto}.overflow-x-visible-ns{overflow-x:visible}.overflow-x-hidden-ns{overflow-x:hidden}.overflow-x-scroll-ns{overflow-x:scroll}.overflow-x-auto-ns{overflow-x:auto}.overflow-y-visible-ns{overflow-y:visible}.overflow-y-hidden-ns{overflow-y:hidden}.overflow-y-scroll-ns{overflow-y:scroll}.overflow-y-auto-ns{overflow-y:auto}.static-ns{position:static}.relative-ns{position:relative}.absolute-ns{position:absolute}.fixed-ns{position:fixed}.rotate-45-ns{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-ns{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-ns{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-ns{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-ns{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-ns{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-ns{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-ns{padding:0}.pa1-ns{padding:.25rem}.pa2-ns{padding:.5rem}.pa3-ns{padding:1rem}.pa4-ns{padding:2rem}.pa5-ns{padding:4rem}.pa6-ns{padding:8rem}.pa7-ns{padding:16rem}.pl0-ns{padding-left:0}.pl1-ns{padding-left:.25rem}.pl2-ns{padding-left:.5rem}.pl3-ns{padding-left:1rem}.pl4-ns{padding-left:2rem}.pl5-ns{padding-left:4rem}.pl6-ns{padding-left:8rem}.pl7-ns{padding-left:16rem}.pr0-ns{padding-right:0}.pr1-ns{padding-right:.25rem}.pr2-ns{padding-right:.5rem}.pr3-ns{padding-right:1rem}.pr4-ns{padding-right:2rem}.pr5-ns{padding-right:4rem}.pr6-ns{padding-right:8rem}.pr7-ns{padding-right:16rem}.pb0-ns{padding-bottom:0}.pb1-ns{padding-bottom:.25rem}.pb2-ns{padding-bottom:.5rem}.pb3-ns{padding-bottom:1rem}.pb4-ns{padding-bottom:2rem}.pb5-ns{padding-bottom:4rem}.pb6-ns{padding-bottom:8rem}.pb7-ns{padding-bottom:16rem}.pt0-ns{padding-top:0}.pt1-ns{padding-top:.25rem}.pt2-ns{padding-top:.5rem}.pt3-ns{padding-top:1rem}.pt4-ns{padding-top:2rem}.pt5-ns{padding-top:4rem}.pt6-ns{padding-top:8rem}.pt7-ns{padding-top:16rem}.pv0-ns{padding-top:0;padding-bottom:0}.pv1-ns{padding-top:.25rem;padding-bottom:.25rem}.pv2-ns{padding-top:.5rem;padding-bottom:.5rem}.pv3-ns{padding-top:1rem;padding-bottom:1rem}.pv4-ns{padding-top:2rem;padding-bottom:2rem}.pv5-ns{padding-top:4rem;padding-bottom:4rem}.pv6-ns{padding-top:8rem;padding-bottom:8rem}.pv7-ns{padding-top:16rem;padding-bottom:16rem}.ph0-ns{padding-left:0;padding-right:0}.ph1-ns{padding-left:.25rem;padding-right:.25rem}.ph2-ns{padding-left:.5rem;padding-right:.5rem}.ph3-ns{padding-left:1rem;padding-right:1rem}.ph4-ns{padding-left:2rem;padding-right:2rem}.ph5-ns{padding-left:4rem;padding-right:4rem}.ph6-ns{padding-left:8rem;padding-right:8rem}.ph7-ns{padding-left:16rem;padding-right:16rem}.ma0-ns{margin:0}.ma1-ns{margin:.25rem}.ma2-ns{margin:.5rem}.ma3-ns{margin:1rem}.ma4-ns{margin:2rem}.ma5-ns{margin:4rem}.ma6-ns{margin:8rem}.ma7-ns{margin:16rem}.ml0-ns{margin-left:0}.ml1-ns{margin-left:.25rem}.ml2-ns{margin-left:.5rem}.ml3-ns{margin-left:1rem}.ml4-ns{margin-left:2rem}.ml5-ns{margin-left:4rem}.ml6-ns{margin-left:8rem}.ml7-ns{margin-left:16rem}.mr0-ns{margin-right:0}.mr1-ns{margin-right:.25rem}.mr2-ns{margin-right:.5rem}.mr3-ns{margin-right:1rem}.mr4-ns{margin-right:2rem}.mr5-ns{margin-right:4rem}.mr6-ns{margin-right:8rem}.mr7-ns{margin-right:16rem}.mb0-ns{margin-bottom:0}.mb1-ns{margin-bottom:.25rem}.mb2-ns{margin-bottom:.5rem}.mb3-ns{margin-bottom:1rem}.mb4-ns{margin-bottom:2rem}.mb5-ns{margin-bottom:4rem}.mb6-ns{margin-bottom:8rem}.mb7-ns{margin-bottom:16rem}.mt0-ns{margin-top:0}.mt1-ns{margin-top:.25rem}.mt2-ns{margin-top:.5rem}.mt3-ns{margin-top:1rem}.mt4-ns{margin-top:2rem}.mt5-ns{margin-top:4rem}.mt6-ns{margin-top:8rem}.mt7-ns{margin-top:16rem}.mv0-ns{margin-top:0;margin-bottom:0}.mv1-ns{margin-top:.25rem;margin-bottom:.25rem}.mv2-ns{margin-top:.5rem;margin-bottom:.5rem}.mv3-ns{margin-top:1rem;margin-bottom:1rem}.mv4-ns{margin-top:2rem;margin-bottom:2rem}.mv5-ns{margin-top:4rem;margin-bottom:4rem}.mv6-ns{margin-top:8rem;margin-bottom:8rem}.mv7-ns{margin-top:16rem;margin-bottom:16rem}.mh0-ns{margin-left:0;margin-right:0}.mh1-ns{margin-left:.25rem;margin-right:.25rem}.mh2-ns{margin-left:.5rem;margin-right:.5rem}.mh3-ns{margin-left:1rem;margin-right:1rem}.mh4-ns{margin-left:2rem;margin-right:2rem}.mh5-ns{margin-left:4rem;margin-right:4rem}.mh6-ns{margin-left:8rem;margin-right:8rem}.mh7-ns{margin-left:16rem;margin-right:16rem}.na1-ns{margin:-.25rem}.na2-ns{margin:-.5rem}.na3-ns{margin:-1rem}.na4-ns{margin:-2rem}.na5-ns{margin:-4rem}.na6-ns{margin:-8rem}.na7-ns{margin:-16rem}.nl1-ns{margin-left:-.25rem}.nl2-ns{margin-left:-.5rem}.nl3-ns{margin-left:-1rem}.nl4-ns{margin-left:-2rem}.nl5-ns{margin-left:-4rem}.nl6-ns{margin-left:-8rem}.nl7-ns{margin-left:-16rem}.nr1-ns{margin-right:-.25rem}.nr2-ns{margin-right:-.5rem}.nr3-ns{margin-right:-1rem}.nr4-ns{margin-right:-2rem}.nr5-ns{margin-right:-4rem}.nr6-ns{margin-right:-8rem}.nr7-ns{margin-right:-16rem}.nb1-ns{margin-bottom:-.25rem}.nb2-ns{margin-bottom:-.5rem}.nb3-ns{margin-bottom:-1rem}.nb4-ns{margin-bottom:-2rem}.nb5-ns{margin-bottom:-4rem}.nb6-ns{margin-bottom:-8rem}.nb7-ns{margin-bottom:-16rem}.nt1-ns{margin-top:-.25rem}.nt2-ns{margin-top:-.5rem}.nt3-ns{margin-top:-1rem}.nt4-ns{margin-top:-2rem}.nt5-ns{margin-top:-4rem}.nt6-ns{margin-top:-8rem}.nt7-ns{margin-top:-16rem}.strike-ns{text-decoration:line-through}.underline-ns{text-decoration:underline}.no-underline-ns{text-decoration:none}.tl-ns{text-align:left}.tr-ns{text-align:right}.tc-ns{text-align:center}.tj-ns{text-align:justify}.ttc-ns{text-transform:capitalize}.ttl-ns{text-transform:lowercase}.ttu-ns{text-transform:uppercase}.ttn-ns{text-transform:none}.f-6-ns,.f-headline-ns{font-size:6rem}.f-5-ns,.f-subheadline-ns{font-size:5rem}.f1-ns{font-size:3rem}.f2-ns{font-size:2.25rem}.f3-ns{font-size:1.5rem}.f4-ns{font-size:1.25rem}.f5-ns{font-size:1rem}.f6-ns{font-size:.875rem}.f7-ns{font-size:.75rem}.measure-ns{max-width:30em}.measure-wide-ns{max-width:34em}.measure-narrow-ns{max-width:20em}.indent-ns{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-ns{font-variant:small-caps}.truncate-ns{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-ns{margin-left:auto}.center-ns,.mr-auto-ns{margin-right:auto}.ml-auto-ns{margin-left:auto}.clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-ns{white-space:normal}.nowrap-ns{white-space:nowrap}.pre-ns{white-space:pre}.v-base-ns{vertical-align:baseline}.v-mid-ns{vertical-align:middle}.v-top-ns{vertical-align:top}.v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em) and (max-width:60em){.aspect-ratio-m{height:0;position:relative}.aspect-ratio--16x9-m{padding-bottom:56.25%}.aspect-ratio--9x16-m{padding-bottom:177.77%}.aspect-ratio--4x3-m{padding-bottom:75%}.aspect-ratio--3x4-m{padding-bottom:133.33%}.aspect-ratio--6x4-m{padding-bottom:66.6%}.aspect-ratio--4x6-m{padding-bottom:150%}.aspect-ratio--8x5-m{padding-bottom:62.5%}.aspect-ratio--5x8-m{padding-bottom:160%}.aspect-ratio--7x5-m{padding-bottom:71.42%}.aspect-ratio--5x7-m{padding-bottom:140%}.aspect-ratio--1x1-m{padding-bottom:100%}.aspect-ratio--object-m{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-m{background-size:cover!important}.contain-m{background-size:contain!important}.bg-center-m{background-position:50%}.bg-center-m,.bg-top-m{background-repeat:no-repeat}.bg-top-m{background-position:top}.bg-right-m{background-position:100%}.bg-bottom-m,.bg-right-m{background-repeat:no-repeat}.bg-bottom-m{background-position:bottom}.bg-left-m{background-repeat:no-repeat;background-position:0}.outline-m{outline:1px solid}.outline-transparent-m{outline:1px solid transparent}.outline-0-m{outline:0}.ba-m{border-style:solid;border-width:1px}.bt-m{border-top-style:solid;border-top-width:1px}.br-m{border-right-style:solid;border-right-width:1px}.bb-m{border-bottom-style:solid;border-bottom-width:1px}.bl-m{border-left-style:solid;border-left-width:1px}.bn-m{border-style:none;border-width:0}.br0-m{border-radius:0}.br1-m{border-radius:.125rem}.br2-m{border-radius:.25rem}.br3-m{border-radius:.5rem}.br4-m{border-radius:1rem}.br-100-m{border-radius:100%}.br-pill-m{border-radius:9999px}.br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.br--top-m{border-bottom-right-radius:0}.br--right-m,.br--top-m{border-bottom-left-radius:0}.br--right-m{border-top-left-radius:0}.br--left-m{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-m{border-style:dotted}.b--dashed-m{border-style:dashed}.b--solid-m{border-style:solid}.b--none-m{border-style:none}.bw0-m{border-width:0}.bw1-m{border-width:.125rem}.bw2-m{border-width:.25rem}.bw3-m{border-width:.5rem}.bw4-m{border-width:1rem}.bw5-m{border-width:2rem}.bt-0-m{border-top-width:0}.br-0-m{border-right-width:0}.bb-0-m{border-bottom-width:0}.bl-0-m{border-left-width:0}.shadow-1-m{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-m{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-m{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-m{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-m{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-m{top:0}.left-0-m{left:0}.right-0-m{right:0}.bottom-0-m{bottom:0}.top-1-m{top:1rem}.left-1-m{left:1rem}.right-1-m{right:1rem}.bottom-1-m{bottom:1rem}.top-2-m{top:2rem}.left-2-m{left:2rem}.right-2-m{right:2rem}.bottom-2-m{bottom:2rem}.top--1-m{top:-1rem}.right--1-m{right:-1rem}.bottom--1-m{bottom:-1rem}.left--1-m{left:-1rem}.top--2-m{top:-2rem}.right--2-m{right:-2rem}.bottom--2-m{bottom:-2rem}.left--2-m{left:-2rem}.absolute--fill-m{top:0;right:0;bottom:0;left:0}.cl-m{clear:left}.cr-m{clear:right}.cb-m{clear:both}.cn-m{clear:none}.dn-m{display:none}.di-m{display:inline}.db-m{display:block}.dib-m{display:inline-block}.dit-m{display:inline-table}.dt-m{display:table}.dtc-m{display:table-cell}.dt-row-m{display:table-row}.dt-row-group-m{display:table-row-group}.dt-column-m{display:table-column}.dt-column-group-m{display:table-column-group}.dt--fixed-m{table-layout:fixed;width:100%}.flex-m{display:flex}.inline-flex-m{display:inline-flex}.flex-auto-m{flex:1 1 auto;min-width:0;min-height:0}.flex-none-m{flex:none}.flex-column-m{flex-direction:column}.flex-row-m{flex-direction:row}.flex-wrap-m{flex-wrap:wrap}.flex-nowrap-m{flex-wrap:nowrap}.flex-wrap-reverse-m{flex-wrap:wrap-reverse}.flex-column-reverse-m{flex-direction:column-reverse}.flex-row-reverse-m{flex-direction:row-reverse}.items-start-m{align-items:flex-start}.items-end-m{align-items:flex-end}.items-center-m{align-items:center}.items-baseline-m{align-items:baseline}.items-stretch-m{align-items:stretch}.self-start-m{align-self:flex-start}.self-end-m{align-self:flex-end}.self-center-m{align-self:center}.self-baseline-m{align-self:baseline}.self-stretch-m{align-self:stretch}.justify-start-m{justify-content:flex-start}.justify-end-m{justify-content:flex-end}.justify-center-m{justify-content:center}.justify-between-m{justify-content:space-between}.justify-around-m{justify-content:space-around}.content-start-m{align-content:flex-start}.content-end-m{align-content:flex-end}.content-center-m{align-content:center}.content-between-m{align-content:space-between}.content-around-m{align-content:space-around}.content-stretch-m{align-content:stretch}.order-0-m{order:0}.order-1-m{order:1}.order-2-m{order:2}.order-3-m{order:3}.order-4-m{order:4}.order-5-m{order:5}.order-6-m{order:6}.order-7-m{order:7}.order-8-m{order:8}.order-last-m{order:99999}.flex-grow-0-m{flex-grow:0}.flex-grow-1-m{flex-grow:1}.flex-shrink-0-m{flex-shrink:0}.flex-shrink-1-m{flex-shrink:1}.fl-m{float:left}.fl-m,.fr-m{_display:inline}.fr-m{float:right}.fn-m{float:none}.i-m{font-style:italic}.fs-normal-m{font-style:normal}.normal-m{font-weight:400}.b-m{font-weight:700}.fw1-m{font-weight:100}.fw2-m{font-weight:200}.fw3-m{font-weight:300}.fw4-m{font-weight:400}.fw5-m{font-weight:500}.fw6-m{font-weight:600}.fw7-m{font-weight:700}.fw8-m{font-weight:800}.fw9-m{font-weight:900}.h1-m{height:1rem}.h2-m{height:2rem}.h3-m{height:4rem}.h4-m{height:8rem}.h5-m{height:16rem}.h-25-m{height:25%}.h-50-m{height:50%}.h-75-m{height:75%}.h-100-m{height:100%}.min-h-100-m{min-height:100%}.vh-25-m{height:25vh}.vh-50-m{height:50vh}.vh-75-m{height:75vh}.vh-100-m{height:100vh}.min-vh-100-m{min-height:100vh}.h-auto-m{height:auto}.h-inherit-m{height:inherit}.tracked-m{letter-spacing:.1em}.tracked-tight-m{letter-spacing:-.05em}.tracked-mega-m{letter-spacing:.25em}.lh-solid-m{line-height:1}.lh-title-m{line-height:1.25}.lh-copy-m{line-height:1.5}.mw-100-m{max-width:100%}.mw1-m{max-width:1rem}.mw2-m{max-width:2rem}.mw3-m{max-width:4rem}.mw4-m{max-width:8rem}.mw5-m{max-width:16rem}.mw6-m{max-width:32rem}.mw7-m{max-width:48rem}.mw8-m{max-width:64rem}.mw9-m{max-width:96rem}.mw-none-m{max-width:none}.w1-m{width:1rem}.w2-m{width:2rem}.w3-m{width:4rem}.w4-m{width:8rem}.w5-m{width:16rem}.w-10-m{width:10%}.w-20-m{width:20%}.w-25-m{width:25%}.w-30-m{width:30%}.w-33-m{width:33%}.w-34-m{width:34%}.w-40-m{width:40%}.w-50-m{width:50%}.w-60-m{width:60%}.w-70-m{width:70%}.w-75-m{width:75%}.w-80-m{width:80%}.w-90-m{width:90%}.w-100-m{width:100%}.w-third-m{width:33.33333%}.w-two-thirds-m{width:66.66667%}.w-auto-m{width:auto}.overflow-visible-m{overflow:visible}.overflow-hidden-m{overflow:hidden}.overflow-scroll-m{overflow:scroll}.overflow-auto-m{overflow:auto}.overflow-x-visible-m{overflow-x:visible}.overflow-x-hidden-m{overflow-x:hidden}.overflow-x-scroll-m{overflow-x:scroll}.overflow-x-auto-m{overflow-x:auto}.overflow-y-visible-m{overflow-y:visible}.overflow-y-hidden-m{overflow-y:hidden}.overflow-y-scroll-m{overflow-y:scroll}.overflow-y-auto-m{overflow-y:auto}.static-m{position:static}.relative-m{position:relative}.absolute-m{position:absolute}.fixed-m{position:fixed}.rotate-45-m{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-m{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-m{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-m{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-m{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-m{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-m{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-m{padding:0}.pa1-m{padding:.25rem}.pa2-m{padding:.5rem}.pa3-m{padding:1rem}.pa4-m{padding:2rem}.pa5-m{padding:4rem}.pa6-m{padding:8rem}.pa7-m{padding:16rem}.pl0-m{padding-left:0}.pl1-m{padding-left:.25rem}.pl2-m{padding-left:.5rem}.pl3-m{padding-left:1rem}.pl4-m{padding-left:2rem}.pl5-m{padding-left:4rem}.pl6-m{padding-left:8rem}.pl7-m{padding-left:16rem}.pr0-m{padding-right:0}.pr1-m{padding-right:.25rem}.pr2-m{padding-right:.5rem}.pr3-m{padding-right:1rem}.pr4-m{padding-right:2rem}.pr5-m{padding-right:4rem}.pr6-m{padding-right:8rem}.pr7-m{padding-right:16rem}.pb0-m{padding-bottom:0}.pb1-m{padding-bottom:.25rem}.pb2-m{padding-bottom:.5rem}.pb3-m{padding-bottom:1rem}.pb4-m{padding-bottom:2rem}.pb5-m{padding-bottom:4rem}.pb6-m{padding-bottom:8rem}.pb7-m{padding-bottom:16rem}.pt0-m{padding-top:0}.pt1-m{padding-top:.25rem}.pt2-m{padding-top:.5rem}.pt3-m{padding-top:1rem}.pt4-m{padding-top:2rem}.pt5-m{padding-top:4rem}.pt6-m{padding-top:8rem}.pt7-m{padding-top:16rem}.pv0-m{padding-top:0;padding-bottom:0}.pv1-m{padding-top:.25rem;padding-bottom:.25rem}.pv2-m{padding-top:.5rem;padding-bottom:.5rem}.pv3-m{padding-top:1rem;padding-bottom:1rem}.pv4-m{padding-top:2rem;padding-bottom:2rem}.pv5-m{padding-top:4rem;padding-bottom:4rem}.pv6-m{padding-top:8rem;padding-bottom:8rem}.pv7-m{padding-top:16rem;padding-bottom:16rem}.ph0-m{padding-left:0;padding-right:0}.ph1-m{padding-left:.25rem;padding-right:.25rem}.ph2-m{padding-left:.5rem;padding-right:.5rem}.ph3-m{padding-left:1rem;padding-right:1rem}.ph4-m{padding-left:2rem;padding-right:2rem}.ph5-m{padding-left:4rem;padding-right:4rem}.ph6-m{padding-left:8rem;padding-right:8rem}.ph7-m{padding-left:16rem;padding-right:16rem}.ma0-m{margin:0}.ma1-m{margin:.25rem}.ma2-m{margin:.5rem}.ma3-m{margin:1rem}.ma4-m{margin:2rem}.ma5-m{margin:4rem}.ma6-m{margin:8rem}.ma7-m{margin:16rem}.ml0-m{margin-left:0}.ml1-m{margin-left:.25rem}.ml2-m{margin-left:.5rem}.ml3-m{margin-left:1rem}.ml4-m{margin-left:2rem}.ml5-m{margin-left:4rem}.ml6-m{margin-left:8rem}.ml7-m{margin-left:16rem}.mr0-m{margin-right:0}.mr1-m{margin-right:.25rem}.mr2-m{margin-right:.5rem}.mr3-m{margin-right:1rem}.mr4-m{margin-right:2rem}.mr5-m{margin-right:4rem}.mr6-m{margin-right:8rem}.mr7-m{margin-right:16rem}.mb0-m{margin-bottom:0}.mb1-m{margin-bottom:.25rem}.mb2-m{margin-bottom:.5rem}.mb3-m{margin-bottom:1rem}.mb4-m{margin-bottom:2rem}.mb5-m{margin-bottom:4rem}.mb6-m{margin-bottom:8rem}.mb7-m{margin-bottom:16rem}.mt0-m{margin-top:0}.mt1-m{margin-top:.25rem}.mt2-m{margin-top:.5rem}.mt3-m{margin-top:1rem}.mt4-m{margin-top:2rem}.mt5-m{margin-top:4rem}.mt6-m{margin-top:8rem}.mt7-m{margin-top:16rem}.mv0-m{margin-top:0;margin-bottom:0}.mv1-m{margin-top:.25rem;margin-bottom:.25rem}.mv2-m{margin-top:.5rem;margin-bottom:.5rem}.mv3-m{margin-top:1rem;margin-bottom:1rem}.mv4-m{margin-top:2rem;margin-bottom:2rem}.mv5-m{margin-top:4rem;margin-bottom:4rem}.mv6-m{margin-top:8rem;margin-bottom:8rem}.mv7-m{margin-top:16rem;margin-bottom:16rem}.mh0-m{margin-left:0;margin-right:0}.mh1-m{margin-left:.25rem;margin-right:.25rem}.mh2-m{margin-left:.5rem;margin-right:.5rem}.mh3-m{margin-left:1rem;margin-right:1rem}.mh4-m{margin-left:2rem;margin-right:2rem}.mh5-m{margin-left:4rem;margin-right:4rem}.mh6-m{margin-left:8rem;margin-right:8rem}.mh7-m{margin-left:16rem;margin-right:16rem}.na1-m{margin:-.25rem}.na2-m{margin:-.5rem}.na3-m{margin:-1rem}.na4-m{margin:-2rem}.na5-m{margin:-4rem}.na6-m{margin:-8rem}.na7-m{margin:-16rem}.nl1-m{margin-left:-.25rem}.nl2-m{margin-left:-.5rem}.nl3-m{margin-left:-1rem}.nl4-m{margin-left:-2rem}.nl5-m{margin-left:-4rem}.nl6-m{margin-left:-8rem}.nl7-m{margin-left:-16rem}.nr1-m{margin-right:-.25rem}.nr2-m{margin-right:-.5rem}.nr3-m{margin-right:-1rem}.nr4-m{margin-right:-2rem}.nr5-m{margin-right:-4rem}.nr6-m{margin-right:-8rem}.nr7-m{margin-right:-16rem}.nb1-m{margin-bottom:-.25rem}.nb2-m{margin-bottom:-.5rem}.nb3-m{margin-bottom:-1rem}.nb4-m{margin-bottom:-2rem}.nb5-m{margin-bottom:-4rem}.nb6-m{margin-bottom:-8rem}.nb7-m{margin-bottom:-16rem}.nt1-m{margin-top:-.25rem}.nt2-m{margin-top:-.5rem}.nt3-m{margin-top:-1rem}.nt4-m{margin-top:-2rem}.nt5-m{margin-top:-4rem}.nt6-m{margin-top:-8rem}.nt7-m{margin-top:-16rem}.strike-m{text-decoration:line-through}.underline-m{text-decoration:underline}.no-underline-m{text-decoration:none}.tl-m{text-align:left}.tr-m{text-align:right}.tc-m{text-align:center}.tj-m{text-align:justify}.ttc-m{text-transform:capitalize}.ttl-m{text-transform:lowercase}.ttu-m{text-transform:uppercase}.ttn-m{text-transform:none}.f-6-m,.f-headline-m{font-size:6rem}.f-5-m,.f-subheadline-m{font-size:5rem}.f1-m{font-size:3rem}.f2-m{font-size:2.25rem}.f3-m{font-size:1.5rem}.f4-m{font-size:1.25rem}.f5-m{font-size:1rem}.f6-m{font-size:.875rem}.f7-m{font-size:.75rem}.measure-m{max-width:30em}.measure-wide-m{max-width:34em}.measure-narrow-m{max-width:20em}.indent-m{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-m{font-variant:small-caps}.truncate-m{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-m{margin-left:auto}.center-m,.mr-auto-m{margin-right:auto}.ml-auto-m{margin-left:auto}.clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-m{white-space:normal}.nowrap-m{white-space:nowrap}.pre-m{white-space:pre}.v-base-m{vertical-align:baseline}.v-mid-m{vertical-align:middle}.v-top-m{vertical-align:top}.v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.aspect-ratio-l{height:0;position:relative}.aspect-ratio--16x9-l{padding-bottom:56.25%}.aspect-ratio--9x16-l{padding-bottom:177.77%}.aspect-ratio--4x3-l{padding-bottom:75%}.aspect-ratio--3x4-l{padding-bottom:133.33%}.aspect-ratio--6x4-l{padding-bottom:66.6%}.aspect-ratio--4x6-l{padding-bottom:150%}.aspect-ratio--8x5-l{padding-bottom:62.5%}.aspect-ratio--5x8-l{padding-bottom:160%}.aspect-ratio--7x5-l{padding-bottom:71.42%}.aspect-ratio--5x7-l{padding-bottom:140%}.aspect-ratio--1x1-l{padding-bottom:100%}.aspect-ratio--object-l{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-l{background-size:cover!important}.contain-l{background-size:contain!important}.bg-center-l{background-position:50%}.bg-center-l,.bg-top-l{background-repeat:no-repeat}.bg-top-l{background-position:top}.bg-right-l{background-position:100%}.bg-bottom-l,.bg-right-l{background-repeat:no-repeat}.bg-bottom-l{background-position:bottom}.bg-left-l{background-repeat:no-repeat;background-position:0}.outline-l{outline:1px solid}.outline-transparent-l{outline:1px solid transparent}.outline-0-l{outline:0}.ba-l{border-style:solid;border-width:1px}.bt-l{border-top-style:solid;border-top-width:1px}.br-l{border-right-style:solid;border-right-width:1px}.bb-l{border-bottom-style:solid;border-bottom-width:1px}.bl-l{border-left-style:solid;border-left-width:1px}.bn-l{border-style:none;border-width:0}.br0-l{border-radius:0}.br1-l{border-radius:.125rem}.br2-l{border-radius:.25rem}.br3-l{border-radius:.5rem}.br4-l{border-radius:1rem}.br-100-l{border-radius:100%}.br-pill-l{border-radius:9999px}.br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.br--top-l{border-bottom-right-radius:0}.br--right-l,.br--top-l{border-bottom-left-radius:0}.br--right-l{border-top-left-radius:0}.br--left-l{border-top-right-radius:0;border-bottom-right-radius:0}.b--dotted-l{border-style:dotted}.b--dashed-l{border-style:dashed}.b--solid-l{border-style:solid}.b--none-l{border-style:none}.bw0-l{border-width:0}.bw1-l{border-width:.125rem}.bw2-l{border-width:.25rem}.bw3-l{border-width:.5rem}.bw4-l{border-width:1rem}.bw5-l{border-width:2rem}.bt-0-l{border-top-width:0}.br-0-l{border-right-width:0}.bb-0-l{border-bottom-width:0}.bl-0-l{border-left-width:0}.shadow-1-l{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-l{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-l{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-l{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-l{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-l{top:0}.left-0-l{left:0}.right-0-l{right:0}.bottom-0-l{bottom:0}.top-1-l{top:1rem}.left-1-l{left:1rem}.right-1-l{right:1rem}.bottom-1-l{bottom:1rem}.top-2-l{top:2rem}.left-2-l{left:2rem}.right-2-l{right:2rem}.bottom-2-l{bottom:2rem}.top--1-l{top:-1rem}.right--1-l{right:-1rem}.bottom--1-l{bottom:-1rem}.left--1-l{left:-1rem}.top--2-l{top:-2rem}.right--2-l{right:-2rem}.bottom--2-l{bottom:-2rem}.left--2-l{left:-2rem}.absolute--fill-l{top:0;right:0;bottom:0;left:0}.cl-l{clear:left}.cr-l{clear:right}.cb-l{clear:both}.cn-l{clear:none}.dn-l{display:none}.di-l{display:inline}.db-l{display:block}.dib-l{display:inline-block}.dit-l{display:inline-table}.dt-l{display:table}.dtc-l{display:table-cell}.dt-row-l{display:table-row}.dt-row-group-l{display:table-row-group}.dt-column-l{display:table-column}.dt-column-group-l{display:table-column-group}.dt--fixed-l{table-layout:fixed;width:100%}.flex-l{display:flex}.inline-flex-l{display:inline-flex}.flex-auto-l{flex:1 1 auto;min-width:0;min-height:0}.flex-none-l{flex:none}.flex-column-l{flex-direction:column}.flex-row-l{flex-direction:row}.flex-wrap-l{flex-wrap:wrap}.flex-nowrap-l{flex-wrap:nowrap}.flex-wrap-reverse-l{flex-wrap:wrap-reverse}.flex-column-reverse-l{flex-direction:column-reverse}.flex-row-reverse-l{flex-direction:row-reverse}.items-start-l{align-items:flex-start}.items-end-l{align-items:flex-end}.items-center-l{align-items:center}.items-baseline-l{align-items:baseline}.items-stretch-l{align-items:stretch}.self-start-l{align-self:flex-start}.self-end-l{align-self:flex-end}.self-center-l{align-self:center}.self-baseline-l{align-self:baseline}.self-stretch-l{align-self:stretch}.justify-start-l{justify-content:flex-start}.justify-end-l{justify-content:flex-end}.justify-center-l{justify-content:center}.justify-between-l{justify-content:space-between}.justify-around-l{justify-content:space-around}.content-start-l{align-content:flex-start}.content-end-l{align-content:flex-end}.content-center-l{align-content:center}.content-between-l{align-content:space-between}.content-around-l{align-content:space-around}.content-stretch-l{align-content:stretch}.order-0-l{order:0}.order-1-l{order:1}.order-2-l{order:2}.order-3-l{order:3}.order-4-l{order:4}.order-5-l{order:5}.order-6-l{order:6}.order-7-l{order:7}.order-8-l{order:8}.order-last-l{order:99999}.flex-grow-0-l{flex-grow:0}.flex-grow-1-l{flex-grow:1}.flex-shrink-0-l{flex-shrink:0}.flex-shrink-1-l{flex-shrink:1}.fl-l{float:left}.fl-l,.fr-l{_display:inline}.fr-l{float:right}.fn-l{float:none}.i-l{font-style:italic}.fs-normal-l{font-style:normal}.normal-l{font-weight:400}.b-l{font-weight:700}.fw1-l{font-weight:100}.fw2-l{font-weight:200}.fw3-l{font-weight:300}.fw4-l{font-weight:400}.fw5-l{font-weight:500}.fw6-l{font-weight:600}.fw7-l{font-weight:700}.fw8-l{font-weight:800}.fw9-l{font-weight:900}.h1-l{height:1rem}.h2-l{height:2rem}.h3-l{height:4rem}.h4-l{height:8rem}.h5-l{height:16rem}.h-25-l{height:25%}.h-50-l{height:50%}.h-75-l{height:75%}.h-100-l{height:100%}.min-h-100-l{min-height:100%}.vh-25-l{height:25vh}.vh-50-l{height:50vh}.vh-75-l{height:75vh}.vh-100-l{height:100vh}.min-vh-100-l{min-height:100vh}.h-auto-l{height:auto}.h-inherit-l{height:inherit}.tracked-l{letter-spacing:.1em}.tracked-tight-l{letter-spacing:-.05em}.tracked-mega-l{letter-spacing:.25em}.lh-solid-l{line-height:1}.lh-title-l{line-height:1.25}.lh-copy-l{line-height:1.5}.mw-100-l{max-width:100%}.mw1-l{max-width:1rem}.mw2-l{max-width:2rem}.mw3-l{max-width:4rem}.mw4-l{max-width:8rem}.mw5-l{max-width:16rem}.mw6-l{max-width:32rem}.mw7-l{max-width:48rem}.mw8-l{max-width:64rem}.mw9-l{max-width:96rem}.mw-none-l{max-width:none}.w1-l{width:1rem}.w2-l{width:2rem}.w3-l{width:4rem}.w4-l{width:8rem}.w5-l{width:16rem}.w-10-l{width:10%}.w-20-l{width:20%}.w-25-l{width:25%}.w-30-l{width:30%}.w-33-l{width:33%}.w-34-l{width:34%}.w-40-l{width:40%}.w-50-l{width:50%}.w-60-l{width:60%}.w-70-l{width:70%}.w-75-l{width:75%}.w-80-l{width:80%}.w-90-l{width:90%}.w-100-l{width:100%}.w-third-l{width:33.33333%}.w-two-thirds-l{width:66.66667%}.w-auto-l{width:auto}.overflow-visible-l{overflow:visible}.overflow-hidden-l{overflow:hidden}.overflow-scroll-l{overflow:scroll}.overflow-auto-l{overflow:auto}.overflow-x-visible-l{overflow-x:visible}.overflow-x-hidden-l{overflow-x:hidden}.overflow-x-scroll-l{overflow-x:scroll}.overflow-x-auto-l{overflow-x:auto}.overflow-y-visible-l{overflow-y:visible}.overflow-y-hidden-l{overflow-y:hidden}.overflow-y-scroll-l{overflow-y:scroll}.overflow-y-auto-l{overflow-y:auto}.static-l{position:static}.relative-l{position:relative}.absolute-l{position:absolute}.fixed-l{position:fixed}.rotate-45-l{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-l{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-l{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-l{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-l{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-l{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-l{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-l{padding:0}.pa1-l{padding:.25rem}.pa2-l{padding:.5rem}.pa3-l{padding:1rem}.pa4-l{padding:2rem}.pa5-l{padding:4rem}.pa6-l{padding:8rem}.pa7-l{padding:16rem}.pl0-l{padding-left:0}.pl1-l{padding-left:.25rem}.pl2-l{padding-left:.5rem}.pl3-l{padding-left:1rem}.pl4-l{padding-left:2rem}.pl5-l{padding-left:4rem}.pl6-l{padding-left:8rem}.pl7-l{padding-left:16rem}.pr0-l{padding-right:0}.pr1-l{padding-right:.25rem}.pr2-l{padding-right:.5rem}.pr3-l{padding-right:1rem}.pr4-l{padding-right:2rem}.pr5-l{padding-right:4rem}.pr6-l{padding-right:8rem}.pr7-l{padding-right:16rem}.pb0-l{padding-bottom:0}.pb1-l{padding-bottom:.25rem}.pb2-l{padding-bottom:.5rem}.pb3-l{padding-bottom:1rem}.pb4-l{padding-bottom:2rem}.pb5-l{padding-bottom:4rem}.pb6-l{padding-bottom:8rem}.pb7-l{padding-bottom:16rem}.pt0-l{padding-top:0}.pt1-l{padding-top:.25rem}.pt2-l{padding-top:.5rem}.pt3-l{padding-top:1rem}.pt4-l{padding-top:2rem}.pt5-l{padding-top:4rem}.pt6-l{padding-top:8rem}.pt7-l{padding-top:16rem}.pv0-l{padding-top:0;padding-bottom:0}.pv1-l{padding-top:.25rem;padding-bottom:.25rem}.pv2-l{padding-top:.5rem;padding-bottom:.5rem}.pv3-l{padding-top:1rem;padding-bottom:1rem}.pv4-l{padding-top:2rem;padding-bottom:2rem}.pv5-l{padding-top:4rem;padding-bottom:4rem}.pv6-l{padding-top:8rem;padding-bottom:8rem}.pv7-l{padding-top:16rem;padding-bottom:16rem}.ph0-l{padding-left:0;padding-right:0}.ph1-l{padding-left:.25rem;padding-right:.25rem}.ph2-l{padding-left:.5rem;padding-right:.5rem}.ph3-l{padding-left:1rem;padding-right:1rem}.ph4-l{padding-left:2rem;padding-right:2rem}.ph5-l{padding-left:4rem;padding-right:4rem}.ph6-l{padding-left:8rem;padding-right:8rem}.ph7-l{padding-left:16rem;padding-right:16rem}.ma0-l{margin:0}.ma1-l{margin:.25rem}.ma2-l{margin:.5rem}.ma3-l{margin:1rem}.ma4-l{margin:2rem}.ma5-l{margin:4rem}.ma6-l{margin:8rem}.ma7-l{margin:16rem}.ml0-l{margin-left:0}.ml1-l{margin-left:.25rem}.ml2-l{margin-left:.5rem}.ml3-l{margin-left:1rem}.ml4-l{margin-left:2rem}.ml5-l{margin-left:4rem}.ml6-l{margin-left:8rem}.ml7-l{margin-left:16rem}.mr0-l{margin-right:0}.mr1-l{margin-right:.25rem}.mr2-l{margin-right:.5rem}.mr3-l{margin-right:1rem}.mr4-l{margin-right:2rem}.mr5-l{margin-right:4rem}.mr6-l{margin-right:8rem}.mr7-l{margin-right:16rem}.mb0-l{margin-bottom:0}.mb1-l{margin-bottom:.25rem}.mb2-l{margin-bottom:.5rem}.mb3-l{margin-bottom:1rem}.mb4-l{margin-bottom:2rem}.mb5-l{margin-bottom:4rem}.mb6-l{margin-bottom:8rem}.mb7-l{margin-bottom:16rem}.mt0-l{margin-top:0}.mt1-l{margin-top:.25rem}.mt2-l{margin-top:.5rem}.mt3-l{margin-top:1rem}.mt4-l{margin-top:2rem}.mt5-l{margin-top:4rem}.mt6-l{margin-top:8rem}.mt7-l{margin-top:16rem}.mv0-l{margin-top:0;margin-bottom:0}.mv1-l{margin-top:.25rem;margin-bottom:.25rem}.mv2-l{margin-top:.5rem;margin-bottom:.5rem}.mv3-l{margin-top:1rem;margin-bottom:1rem}.mv4-l{margin-top:2rem;margin-bottom:2rem}.mv5-l{margin-top:4rem;margin-bottom:4rem}.mv6-l{margin-top:8rem;margin-bottom:8rem}.mv7-l{margin-top:16rem;margin-bottom:16rem}.mh0-l{margin-left:0;margin-right:0}.mh1-l{margin-left:.25rem;margin-right:.25rem}.mh2-l{margin-left:.5rem;margin-right:.5rem}.mh3-l{margin-left:1rem;margin-right:1rem}.mh4-l{margin-left:2rem;margin-right:2rem}.mh5-l{margin-left:4rem;margin-right:4rem}.mh6-l{margin-left:8rem;margin-right:8rem}.mh7-l{margin-left:16rem;margin-right:16rem}.na1-l{margin:-.25rem}.na2-l{margin:-.5rem}.na3-l{margin:-1rem}.na4-l{margin:-2rem}.na5-l{margin:-4rem}.na6-l{margin:-8rem}.na7-l{margin:-16rem}.nl1-l{margin-left:-.25rem}.nl2-l{margin-left:-.5rem}.nl3-l{margin-left:-1rem}.nl4-l{margin-left:-2rem}.nl5-l{margin-left:-4rem}.nl6-l{margin-left:-8rem}.nl7-l{margin-left:-16rem}.nr1-l{margin-right:-.25rem}.nr2-l{margin-right:-.5rem}.nr3-l{margin-right:-1rem}.nr4-l{margin-right:-2rem}.nr5-l{margin-right:-4rem}.nr6-l{margin-right:-8rem}.nr7-l{margin-right:-16rem}.nb1-l{margin-bottom:-.25rem}.nb2-l{margin-bottom:-.5rem}.nb3-l{margin-bottom:-1rem}.nb4-l{margin-bottom:-2rem}.nb5-l{margin-bottom:-4rem}.nb6-l{margin-bottom:-8rem}.nb7-l{margin-bottom:-16rem}.nt1-l{margin-top:-.25rem}.nt2-l{margin-top:-.5rem}.nt3-l{margin-top:-1rem}.nt4-l{margin-top:-2rem}.nt5-l{margin-top:-4rem}.nt6-l{margin-top:-8rem}.nt7-l{margin-top:-16rem}.strike-l{text-decoration:line-through}.underline-l{text-decoration:underline}.no-underline-l{text-decoration:none}.tl-l{text-align:left}.tr-l{text-align:right}.tc-l{text-align:center}.tj-l{text-align:justify}.ttc-l{text-transform:capitalize}.ttl-l{text-transform:lowercase}.ttu-l{text-transform:uppercase}.ttn-l{text-transform:none}.f-6-l,.f-headline-l{font-size:6rem}.f-5-l,.f-subheadline-l{font-size:5rem}.f1-l{font-size:3rem}.f2-l{font-size:2.25rem}.f3-l{font-size:1.5rem}.f4-l{font-size:1.25rem}.f5-l{font-size:1rem}.f6-l{font-size:.875rem}.f7-l{font-size:.75rem}.measure-l{max-width:30em}.measure-wide-l{max-width:34em}.measure-narrow-l{max-width:20em}.indent-l{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-l{font-variant:small-caps}.truncate-l{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-l{margin-left:auto}.center-l,.mr-auto-l{margin-right:auto}.ml-auto-l{margin-left:auto}.clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-l{white-space:normal}.nowrap-l{white-space:nowrap}.pre-l{white-space:pre}.v-base-l{vertical-align:baseline}.v-mid-l{vertical-align:middle}.v-top-l{vertical-align:top}.v-btm-l{vertical-align:bottom}} 3 | 4 | -------------------------------------------------------------------------------- /public/ui.css: -------------------------------------------------------------------------------- 1 | :host, 2 | :root { 3 | color-scheme: light dark; 4 | font-size: initial; 5 | font-family: initial; 6 | --font: -apple-system, BlinkMacSystemFont, 'avenir next', avenir, helvetica, 'helvetica neue', 7 | ubuntu, roboto, noto, 'segoe ui', arial, sans-serif; 8 | --code-font: Consolas, monaco, monospace; 9 | --font-size: 1.25rem; 10 | --bg-rgb: 222, 225, 230; 11 | --bg-color: rgb(var(--bg-rgb)); 12 | --bg-border-color: rgb(218, 220, 224); 13 | --text-color: #333333; 14 | --link-text-color: #463f5c; 15 | --active-link-text-color: #000; 16 | --link-underscore-color: #a9b2d1; 17 | --selection-color: rgb(177, 214, 252); 18 | } 19 | 20 | @media (prefers-color-scheme: dark) { 21 | :host, 22 | :root { 23 | --bg-rgb: 53, 54, 58; 24 | --active-bg-color: rgb(60, 60, 60); 25 | --bg-border-color: rgb(43, 44, 48); 26 | --border-color: rgb(80, 80, 80); 27 | --text-color: #b8bfc6; 28 | --select-text-bg-color: #3a505f; 29 | --link-text-color: #aaa; 30 | --active-link-text-color: #fff; 31 | --link-underscore-color: #a9b2d1; 32 | --header-color: #dedede; 33 | } 34 | } 35 | 36 | .panel.disabled { 37 | } 38 | 39 | .ui.active.siblinks .panel.siblinks, 40 | .ui.active.simlinks .panel.simlinks, 41 | .ui.active.backlinks .panel.backlinks, 42 | .ui.thumb:hover + .panel, 43 | .panel.active, 44 | .simlinks.panel.ready { 45 | right: 0; 46 | 47 | box-shadow: rgba(0, 0, 0, 0.8) -50px 0 80px; 48 | } 49 | 50 | .panel { 51 | right: -35em; 52 | transition: right 0.15s cubic-bezier(0.65, 0.05, 0.36, 1), box-shadow 0.15s ease-in; 53 | overflow: auto; 54 | width: 32em; 55 | height: 100vh; 56 | position: fixed; 57 | display: flex; 58 | z-index: 99999995; 59 | font-family: var(--font); 60 | font-style: var(--font-size); 61 | background-color: var(--bg-color); 62 | border-top: 1px solid var(--bg-border-color); 63 | display: flex; 64 | flex-direction: column; 65 | 66 | box-shadow: rgba(0, 0, 0, 0) -50px 0 80px; 67 | } 68 | 69 | .tooltip { 70 | display: block; 71 | position: absolute; 72 | z-index: 99999993; 73 | margin: 0; 74 | background: var(--bg-color); 75 | color: var(--text-color); 76 | padding: 0px; 77 | border: none; 78 | border-radius: 3px; 79 | transform: translateX(-50%) translateY(10px); 80 | /* padding: 0; */ 81 | font-family: var(--font); 82 | font-size: 0.8em; 83 | box-sizing: border-box; 84 | /* margin-right: -10px; */ 85 | font-family: var(--font); 86 | animation: disappear 0s forwards; 87 | } 88 | .tooltip::before { 89 | content: ''; 90 | display: inline-block; 91 | position: absolute; 92 | z-index: -1; 93 | width: 12px; 94 | height: 12px; 95 | left: 50%; 96 | top: 0; 97 | transform: translateX(-50%) translateY(-50%) rotate(45deg); 98 | background: inherit; 99 | display: none; 100 | } 101 | 102 | .debug { 103 | position: absolute; 104 | z-index: 999999991; 105 | background: red; 106 | opacity: 0.3; 107 | display: inline-block; 108 | pointer-events: none; 109 | } 110 | 111 | h2 { 112 | font-variant: small-caps; 113 | font-size: 12px; 114 | margin: 0.5em; 115 | } 116 | 117 | .marked span { 118 | padding: 0.25rem; 119 | color: var(--header-color); 120 | } 121 | 122 | li { 123 | color: var(--link-text-color); 124 | } 125 | 126 | ul.simlink, 127 | ul.link { 128 | display: flex; 129 | justify-content: space-evenly; 130 | flex-flow: column; 131 | padding: 0; 132 | margin: 0; 133 | } 134 | 135 | li.simlink, 136 | li.link { 137 | list-style: none; 138 | flex: 1; 139 | margin: 0.5em; 140 | border-radius: 14px; 141 | max-width: 32em; 142 | } 143 | 144 | ul.keyword { 145 | display: flex; 146 | flex-flow: row; 147 | flex-wrap: wrap; 148 | padding: 0.25rem; 149 | margin: 0; 150 | } 151 | 152 | li.keyword { 153 | list-style: none; 154 | display: inline-flex; 155 | padding: 2px; 156 | } 157 | 158 | .query { 159 | margin: 0 0.5em; 160 | padding: 0.25rem; 161 | } 162 | 163 | .query .name { 164 | color: var(--active-link-text-color); 165 | font-weight: bold; 166 | padding-right: 3px; 167 | } 168 | 169 | .query .input { 170 | color: var(--text-color); 171 | } 172 | 173 | .context { 174 | font-size: 0.75rem; 175 | font-style: italic; 176 | text-overflow: ellipsis; 177 | margin-left: 1rem; 178 | padding-top: 0.25rem; 179 | } 180 | 181 | a { 182 | color: var(--link-text-color); 183 | text-decoration: none; 184 | transition: color 0.15s ease-in; 185 | 186 | border-bottom: 2px solid var(--link-underscore-color); 187 | } 188 | 189 | a:hover { 190 | color: var(--active-link-text-color); 191 | border-color: var(--active-link-text-color); 192 | } 193 | 194 | a.keyword, 195 | .tag { 196 | font-size: 0.5em; 197 | border-radius: 4px; 198 | padding: 2px; 199 | margin: 4px; 200 | border: 1px solid var(--bg-border-color); 201 | } 202 | a.keyword:hover, 203 | .tag:hover { 204 | color: var(--text-color); 205 | border-color: var(--bg-border-color); 206 | background: var(--bg-border-color); 207 | } 208 | 209 | /* ------------------------------------------------- */ 210 | 211 | .thumb { 212 | position: fixed; 213 | z-index: 99999991; 214 | width: 44px; 215 | height: auto; 216 | overflow: hidden; 217 | 218 | border-radius: 50%; 219 | width: 44px; 220 | height: 44px; 221 | overflow: hidden; 222 | background-color: var(--bg-color); 223 | bottom: 10px; 224 | right: 10px; 225 | mask: url(double-dagger.svg) 0px 0px; 226 | -webkit-mask: url(double-dagger.svg) 0px 0px; 227 | mask-size: cover; 228 | -webkit-mask-size: cover; 229 | box-shadow: 0 0 18px rgba(100, 100, 100, 0.5); 230 | border: none; 231 | /* -webkit-mask-repeat: no-repeat; */ 232 | /* -webkit-mask-position: center; */ 233 | /* -webkit-mask-origin: padding; */ 234 | /* clip-path: url(back-link-alt-solid.svg); */ 235 | } 236 | 237 | .thumb.hide { 238 | transform: scale(0); 239 | } 240 | 241 | .thumb { 242 | animation: expand 0.5s ease; 243 | } 244 | 245 | .thumb.disabled { 246 | transform: scale(1); 247 | /* TODO: Show different icon to signify no connections ? */ 248 | opacity: 0.5; 249 | mask: url(double-dagger.svg) 0px 0px; 250 | -webkit-mask: url(double-dagger.svg) 0px 0px; 251 | mask-size: cover; 252 | -webkit-mask-size: cover; 253 | pointer-events: none; 254 | } 255 | 256 | .thumb.show { 257 | transform: scale(1); 258 | } 259 | 260 | @keyframes expand { 261 | from { 262 | transform: scale(0); 263 | } 264 | } 265 | 266 | @keyframes appear { 267 | 0% { 268 | pointer-events: none; 269 | } 270 | 100% { 271 | opacity: 1; 272 | pointer-events: all; 273 | } 274 | } 275 | 276 | @keyframes disappear { 277 | 0% { 278 | pointer-events: all; 279 | } 280 | 100% { 281 | opacity: 0; 282 | pointer-events: none; 283 | } 284 | } 285 | 286 | @keyframes spin { 287 | 0% { 288 | -webkit-transform: rotate(0deg); 289 | transform: rotate(0deg); 290 | } 291 | 100% { 292 | -webkit-transform: rotate(360deg); 293 | transform: rotate(360deg); 294 | } 295 | } 296 | 297 | details > summary::marker { 298 | content: ''; 299 | } 300 | 301 | details > summary::-webkit-details-marker { 302 | content: ''; 303 | display: none; 304 | } 305 | 306 | .card:hover { 307 | background-color: var(--active-bg-color); 308 | } 309 | 310 | .card { 311 | flex: 1 1 0%; 312 | cursor: pointer; 313 | align-items: flex-end; 314 | display: flex; 315 | border-radius: 14px; 316 | max-width: 32em; 317 | flex-direction: row; 318 | align-items: stretch; 319 | overflow: hidden; 320 | border: 1px solid var(--border-color); 321 | height: 8rem; 322 | position: relative; 323 | } 324 | 325 | .card-image { 326 | overflow: hidden; 327 | height: 100%; 328 | max-width: 50%; 329 | width: 10em; 330 | min-width: 8em; 331 | border-right-width: 1px; 332 | background-size: 3em; 333 | background-position: center; 334 | background-repeat: no-repeat; 335 | background-color: white; 336 | } 337 | 338 | .card .image { 339 | width: 100%; 340 | height: 100%; 341 | object-fit: cover; 342 | } 343 | 344 | .card-content { 345 | margin: 0; 346 | justify-content: center; 347 | flex-shrink: 1; 348 | flex-grow: 1; 349 | align-items: stretch; 350 | flex-basis: auto; 351 | flex-direction: column; 352 | overflow: hidden; 353 | position: relative; 354 | padding: 0.6em; 355 | box-sizing: border-box; 356 | } 357 | 358 | .card .title { 359 | font-size: 0.92em; 360 | color: var(--header-color); 361 | max-height: 1.3em; 362 | white-space: nowrap; 363 | overflow: hidden; 364 | text-overflow: ellipsis; 365 | display: block; 366 | border: none; 367 | } 368 | 369 | .card .description { 370 | font-size: 0.86rem; 371 | max-height: 5.4em; 372 | margin: 0; 373 | overflow: hidden; 374 | text-align: left; 375 | text-overflow: ellipsis; 376 | } 377 | 378 | .url:hover { 379 | text-decoration: underline; 380 | color: var(--text-color); 381 | } 382 | .url:hover .site-icon { 383 | opacity: 1; 384 | } 385 | 386 | .card .url { 387 | display: block; 388 | font-size: 0.72em; 389 | overflow: hidden; 390 | text-overflow: ellipsis; 391 | white-space: nowrap; 392 | margin: 0; 393 | border: none; 394 | line-height: 1.4em; 395 | height: 1.4em; 396 | margin-top: 6px; 397 | vertical-align: middle; 398 | align-items: initial; 399 | } 400 | 401 | .card .site-icon { 402 | opacity: 0.8; 403 | width: 1rem; 404 | height: 1rem; 405 | margin-right: 6px; 406 | float: left; 407 | border-radius: 3px; 408 | } 409 | 410 | .double-dagger { 411 | font-family: var(--code-font); 412 | pointer-events: none; 413 | } 414 | 415 | .badge { 416 | position: absolute; 417 | cursor: pointer; 418 | display: inline-block; 419 | z-index: 99999993; 420 | margin: 0; 421 | background-color: var(--bg-color); 422 | color: var(--active-link-text-color); 423 | padding: 2px 6px 2px 3px; 424 | min-width: 22px; 425 | min-height: 20px; 426 | border: none; 427 | border-radius: 3px; 428 | transform: translateX(10px); 429 | margin-top: -10px; 430 | font-family: var(--font); 431 | font-size: 0.8em; 432 | outline: none; 433 | } 434 | 435 | .badge::before { 436 | content: ''; 437 | display: inline-block; 438 | position: absolute; 439 | z-index: -1; 440 | width: 13px; 441 | height: 13px; 442 | left: 1px; 443 | top: 10px; 444 | transform: translateX(-100%) translateY(-50%); 445 | background: transparent; 446 | width: 0; 447 | height: 0; 448 | border: 9px solid transparent; 449 | border-right-color: var(--bg-color); 450 | } 451 | 452 | .badge::after { 453 | content: ''; 454 | display: inline-block; 455 | } 456 | 457 | .badge { 458 | transition: min-width 0.3s ease-out, min-height 0.3s ease-out, opacity 0.3s ease; 459 | opacity: 0.6; 460 | transform-origin: top left; 461 | } 462 | 463 | .badge:hover { 464 | opacity: 1; 465 | } 466 | 467 | .badge.out:not(:hover) { 468 | transition-delay: 0.3s; 469 | opacity: 0; 470 | } 471 | 472 | .badge .icon { 473 | border-radius: 50%; 474 | width: 22px; 475 | height: 22px; 476 | mask: url(double-dagger.svg) 0px 0px; 477 | -webkit-mask: url(double-dagger.svg) 0px 0px; 478 | mask-size: cover; 479 | -webkit-mask-size: cover; 480 | box-shadow: 0 0 18px rgba(100, 100, 100, 0.5); 481 | border: none; 482 | margin: 3px; 483 | background-color: var(--active-link-text-color); 484 | pointer-events: none; 485 | } 486 | /* ---------------- BUBBLE --------------- */ 487 | 488 | .bubble { 489 | position: absolute; 490 | cursor: pointer; 491 | display: inline-block; 492 | z-index: 99999993; 493 | margin: 0; 494 | background-color: transparent; 495 | font-family: var(--font); 496 | font-size: 0.8em; 497 | outline: none; 498 | box-shadow: 3px 3px 20px 0px rgba(0, 0, 0, 0.4); 499 | outline: none; 500 | border: none; 501 | margin: 0; 502 | padding: 0; 503 | border-radius: 0 5px 5px 0px; 504 | opacity: 1; 505 | } 506 | 507 | .bubble.backward { 508 | transform: translateX(-100%); 509 | border-radius: 5px 0 0 5px; 510 | } 511 | 512 | .bubble::before { 513 | content: ''; 514 | display: inline-block; 515 | position: absolute; 516 | background: var(--bg-color); 517 | width: 1px; 518 | height: 200%; 519 | top: -50%; 520 | box-shadow: 0px 0px 13px 1px rgba(0, 0, 0, 1); 521 | } 522 | 523 | .bubble .summary, 524 | .bubble .details { 525 | opacity: 0.3; 526 | background: var(--bg-color); 527 | transition: opacity ease 0.2s; 528 | } 529 | 530 | .bubble .details { 531 | opacity: 0; 532 | pointer-events: none; 533 | } 534 | .bubble:hover .details { 535 | transition: opacity ease 0.2s 0.5s; 536 | pointer-events: all; 537 | } 538 | 539 | .bubble:hover .summary, 540 | .bubble:hover .details { 541 | opacity: 0.98; 542 | } 543 | 544 | .bubble.out:not(:hover) { 545 | transition-delay: 0.3s; 546 | opacity: 0; 547 | } 548 | 549 | .bubble .summary { 550 | position: relative; 551 | padding: 0 5px; 552 | outline: none; 553 | color: var(--active-link-text-color); 554 | height: 100%; 555 | font-size: 0.8em; 556 | box-sizing: border-box; 557 | pointer-events: none; 558 | vertical-align: middle; 559 | line-height: inherit; 560 | } 561 | 562 | .bubble.forward .summary { 563 | border-radius: inherit; 564 | } 565 | 566 | .bubble.backward .summary { 567 | right: 1px; 568 | border-radius: 5px 0px 0px 5px; 569 | } 570 | 571 | .bubble .details { 572 | pointer-events: none; 573 | position: absolute; 574 | border-radius: 5px; 575 | box-shadow: inherit; 576 | font-size: 0.7rem; 577 | line-height: initial; 578 | text-align: initial; 579 | margin-top: -3px; 580 | } 581 | 582 | .bubble.forward::before { 583 | left: 0; 584 | } 585 | 586 | .bubble.backward::before { 587 | right: 0; 588 | } 589 | 590 | .bubble.backward .details { 591 | left: 0; 592 | } 593 | 594 | .bubble.forward .details { 595 | right: 0; 596 | } 597 | 598 | .bubble .details h2, 599 | .tooltip > h2 { 600 | display: none; 601 | } 602 | 603 | .bubble ul.simlink, 604 | .bubble ul.link, 605 | .tooltip ul.simlink, 606 | .tooltip ul.link { 607 | max-height: 18em; 608 | width: 32em; 609 | overflow: auto; 610 | scroll-snap-type: y mandatory; 611 | justify-content: flex-start; 612 | padding: 0; 613 | margin: 0; 614 | border-radius: inherit; 615 | transition: width 0.2s ease-out, heigth 0.2s ease-out; 616 | transform-origin: top left; 617 | transition: opacity 0.5s ease; 618 | } 619 | 620 | .bubble li.simlink, 621 | .bubble li.link, 622 | .tooltip li.simlink, 623 | .tooltip li.link { 624 | padding: 0; 625 | margin: 0; 626 | height: 6em; 627 | scroll-snap-align: start; 628 | } 629 | 630 | .bubble .card, 631 | .tooltip .card { 632 | height: 6em; 633 | max-width: 100%; 634 | border-radius: 0; 635 | border: none; 636 | border-bottom: 1px solid var(--border-color); 637 | } 638 | 639 | .bubble .card-image, 640 | .tooltip .card-image { 641 | width: 6em; 642 | min-width: 6em; 643 | } 644 | 645 | .bubble .card .title, 646 | .tooltip .card .title { 647 | font-size: 1em; 648 | } 649 | 650 | .bubble .card .description, 651 | .tooltip .card .description { 652 | font-size: 0.85em; 653 | max-height: 2.6em; 654 | } 655 | 656 | .bubble .card .description p, 657 | .tooltip .card .description p { 658 | margin: 0; 659 | } 660 | 661 | .bubble .card .url, 662 | .tooltip .card .url { 663 | height: 1.4em; 664 | line-height: 1.4em; 665 | } 666 | .bubble .card .site-icon, 667 | .tooltip .card .site-icon { 668 | height: 1.4em; 669 | width: 1.4em; 670 | } 671 | 672 | :host-context(.ksp-browser-active) .viewport, 673 | .viewport.active { 674 | transform: perspective(1000px) translate3d(0, 0, -300px); 675 | } 676 | 677 | .viewport { 678 | position: fixed; 679 | width: 100vw; 680 | height: 100vh; 681 | top: 0; 682 | left: 0; 683 | margin: 0; 684 | padding: 0; 685 | z-index: 99999991; 686 | pointer-events: none; 687 | transform: perspective(1000px) translate3d(0, 0, 0); 688 | transition: transform 0.2s ease; 689 | } 690 | 691 | :host-context(.ksp-browser-active) .overlay { 692 | opacity: 0; 693 | } 694 | 695 | .overlay { 696 | pointer-events: all; 697 | } 698 | 699 | .viewport .frame { 700 | position: absolute; 701 | background: var(--bg-color); 702 | pointer-events: all; 703 | } 704 | 705 | .viewport .frame.top { 706 | left: 0; 707 | top: -100vh; 708 | width: 100vw; 709 | height: 100vh; 710 | } 711 | 712 | .viewport .frame.bottom { 713 | left: 0; 714 | top: 100vh; 715 | width: 100vw; 716 | height: 100vh; 717 | } 718 | 719 | .viewport .frame.left { 720 | left: -100vw; 721 | top: -100vh; 722 | width: 100vw; 723 | height: 300vh; 724 | } 725 | 726 | .viewport .frame.right { 727 | left: 100vw; 728 | top: -100vh; 729 | width: 100vw; 730 | height: 300vh; 731 | } 732 | 733 | .viewport .frame.center { 734 | background: none; 735 | pointer-events: none; 736 | width: 100vw; 737 | height: 100vh; 738 | box-shadow: rgba(0, 0, 0, 0.4) -50px 0 80px; 739 | } 740 | -------------------------------------------------------------------------------- /public/ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Related documents 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 16 |
19 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /public/website-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionInbox, AgentInbox, LookupResponse, IngestResponse, OpenResponse } from './mailbox' 2 | import * as Protocol from './protocol' 3 | import * as URL from './url' 4 | 5 | const onRequest = async ( 6 | message: ExtensionInbox, 7 | { tab }: chrome.runtime.MessageSender 8 | ): Promise => { 9 | switch (message.type) { 10 | case 'CloseRequest': { 11 | close() 12 | return null 13 | } 14 | case 'OpenRequest': { 15 | const result = await open(message.url) 16 | return { type: 'OpenResponse', open: result } 17 | } 18 | case 'LookupRequest': { 19 | chrome.browserAction.setBadgeText({ text: ``, tabId: tab!.id }) 20 | const resource = await lookup(message.lookup) 21 | const count = new Set(resource.backLinks.map((link) => link.referrer.url)).size 22 | chrome.browserAction.setBadgeText({ text: `${count}`, tabId: tab!.id }) 23 | 24 | return { type: 'LookupResponse', resource } 25 | } 26 | case 'IngestRequest': { 27 | const output = await ingest(message.resource) 28 | return { type: 'IngestResponse', ingest: output } 29 | } 30 | case 'TagsRequest': { 31 | return null 32 | } 33 | case 'SimilarRequest': { 34 | const url = URL.from(document.URL, { hash: '' }).href 35 | const output = await similar(message.input) 36 | return { type: 'SimilarResponse', similar: output, id: message.id } 37 | } 38 | } 39 | } 40 | 41 | enum Command { 42 | InspectLinks = 'inspect-links', 43 | TogglePanel = 'toggle-panel', 44 | } 45 | 46 | const onCommand = (command: Command): void => { 47 | switch (command) { 48 | case Command.InspectLinks: 49 | return void inspectLinks() 50 | case Command.TogglePanel: 51 | return void togglePanel() 52 | } 53 | } 54 | 55 | const inspectLinks = async () => { 56 | const tab = await getSelectedTab() 57 | if (tab) { 58 | sendAgentMessage(tab, { type: 'InspectLinksRequest' }) 59 | } 60 | } 61 | 62 | const togglePanel = async () => { 63 | const tab = await getSelectedTab() 64 | if (tab) { 65 | sendAgentMessage(tab, { type: 'Toggle' }) 66 | } 67 | } 68 | 69 | const getSelectedTab = (): Promise => 70 | new Promise((resolve) => chrome.tabs.getSelected(resolve)) 71 | 72 | const open = async (url: string): Promise => 73 | ksp( 74 | { 75 | query: `mutation open { 76 | open(url:${JSON.stringify(url)}) { 77 | openOk 78 | exitOk 79 | code 80 | } 81 | }`, 82 | operationName: 'open', 83 | variables: {}, 84 | }, 85 | (data): Protocol.Open => data.open 86 | ) 87 | 88 | const ingest = async (resource: Protocol.InputResource): Promise => 89 | ksp( 90 | { 91 | operationName: 'Ingest', 92 | variables: { resource }, 93 | query: `mutation Ingest($resource:InputResource!) { 94 | ingest(resource:$resource) { 95 | sibLinks: links { 96 | ...sibLink 97 | } 98 | } 99 | } 100 | 101 | fragment sibLink on Link { 102 | target { 103 | url 104 | backLinks { 105 | ...backLink 106 | } 107 | tags { 108 | ...tag 109 | } 110 | } 111 | } 112 | 113 | fragment tag on Tag { 114 | name 115 | } 116 | 117 | fragment backLink on Link { 118 | kind 119 | identifier 120 | name 121 | title 122 | fragment 123 | location 124 | referrer { 125 | url 126 | info { 127 | title 128 | description 129 | cid 130 | icon 131 | image 132 | } 133 | tags { 134 | ...tag 135 | } 136 | } 137 | }`, 138 | }, 139 | (data) => data.ingest 140 | ) 141 | 142 | const lookup = async (url: string): Promise => 143 | ksp( 144 | { 145 | operationName: 'Lookup', 146 | variables: {}, 147 | query: `query Lookup { 148 | resource(url: "${url}") { 149 | url 150 | backLinks { 151 | ...backLink 152 | } 153 | tags { 154 | ...tag 155 | } 156 | } 157 | } 158 | 159 | fragment tag on Tag { 160 | name 161 | } 162 | 163 | fragment backLink on Link { 164 | kind 165 | identifier 166 | name 167 | title 168 | fragment 169 | location 170 | referrer { 171 | url 172 | info { 173 | title 174 | description 175 | cid 176 | icon 177 | image 178 | } 179 | tags { 180 | ...tag 181 | } 182 | } 183 | }`, 184 | }, 185 | (data) => data.resource 186 | ) 187 | 188 | const similar = async (input: Protocol.InputSimilar): Promise => 189 | ksp( 190 | { 191 | operationName: 'Similar', 192 | variables: { input }, 193 | query: `query Similar($input:InputSimilar!) { 194 | similar(input:$input) { 195 | keywords 196 | similar { 197 | score, 198 | resource { 199 | url 200 | info { 201 | icon 202 | image 203 | title 204 | description 205 | } 206 | } 207 | } 208 | } 209 | }`, 210 | }, 211 | (data) => data.similar 212 | ) 213 | 214 | const ksp = async (input: a, decode: (output: any) => b): Promise => { 215 | const request = await fetch('http://localhost:8080/graphql', { 216 | method: 'POST', 217 | headers: { 218 | contentType: 'application/json', 219 | }, 220 | body: JSON.stringify(input), 221 | }) 222 | const json = await request.json() 223 | return decode(json.data) 224 | } 225 | 226 | const close = () => {} 227 | const executeScript = (details: chrome.tabs.InjectDetails): Promise => 228 | new Promise((resolve, reject) => { 229 | chrome.tabs.executeScript(details, (results) => { 230 | const error = chrome.runtime.lastError 231 | if (error !== undefined) { 232 | reject(error) 233 | } else { 234 | resolve(results) 235 | } 236 | }) 237 | }) 238 | 239 | class NoReceiverError extends Error { 240 | error: chrome.runtime.LastError 241 | constructor(error: chrome.runtime.LastError) { 242 | super(error.message) 243 | this.error = error 244 | } 245 | } 246 | 247 | const executeFunction =
(fn: () => a, details: chrome.tabs.InjectDetails = {}): Promise => { 248 | delete details.file 249 | return executeScript({ 250 | ...details, 251 | code: `(${fn})()`, 252 | }) 253 | } 254 | 255 | const request = (tabId: number, message: a): Promise => 256 | new Promise((resolve, reject) => { 257 | chrome.tabs.sendMessage(tabId, message, (response) => { 258 | if (response) { 259 | resolve(response) 260 | } else if (chrome.runtime.lastError) { 261 | const error = chrome.runtime.lastError 262 | if (!error) { 263 | resolve(response) 264 | } 265 | if (error.message && error.message.includes('Receiving end does not exist')) { 266 | reject(new NoReceiverError(error)) 267 | } else { 268 | reject(error) 269 | } 270 | } 271 | }) 272 | }) 273 | 274 | const sendAgentMessage = (tab: chrome.tabs.Tab, message: AgentInbox) => 275 | chrome.tabs.sendMessage(tab.id!, message) 276 | 277 | chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { 278 | let response = onRequest(request, sender) 279 | if (response) { 280 | Promise.resolve(response).then(sendResponse) 281 | } 282 | return true 283 | }) 284 | // chrome.browserAction.onClicked.addListener(onBrowserAction) 285 | // chrome.contextMenus.onClicked.addListener(onContextMenuAction) 286 | 287 | chrome.browserAction.setBadgeBackgroundColor({ color: '#000' }) 288 | chrome.browserAction.onClicked.addListener((tab) => sendAgentMessage(tab, { type: 'Toggle' })) 289 | chrome.commands.onCommand.addListener(<(command: string) => void>onCommand) 290 | -------------------------------------------------------------------------------- /src/backlinks.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing, Template, TemplateResult } from '../node_modules/lit-html/lit-html' 2 | import { Link, Tag, Resource } from './protocol' 3 | import { viewLinks } from './links' 4 | import { Mode } from './mode' 5 | 6 | type Model = { 7 | mode: Mode 8 | resource: Resource | null 9 | } 10 | 11 | export const view = ({ resource, mode }: Model) => { 12 | const links = resource ? resource.backLinks : [] 13 | return html`` 16 | } 17 | -------------------------------------------------------------------------------- /src/blob-reader.ts: -------------------------------------------------------------------------------- 1 | enum ReadAs { 2 | ArrayBuffer, 3 | BinaryString, 4 | DataURL, 5 | Text, 6 | } 7 | 8 | export class BlobReader extends FileReader { 9 | onsucceed: null | ((value: t) => void) 10 | onfail: null | ((error: DOMException) => void) 11 | blob: Blob 12 | read: (blob: Blob) => void 13 | constructor(read: (blob: Blob) => void, blob: Blob) { 14 | super() 15 | this.blob = blob 16 | this.onsucceed = null 17 | this.onfail = null 18 | this.read = read 19 | } 20 | then(succeed: (value: t) => void, fail: (error: DOMException) => void) { 21 | this.onsucceed = succeed 22 | this.onfail = fail 23 | this.read(this.blob) 24 | } 25 | 26 | static onload(event: ProgressEvent>) { 27 | const { target: reader } = event 28 | reader!.onsucceed!(reader!.result!) 29 | } 30 | static onerror(event: ProgressEvent>) { 31 | const { target: reader } = event 32 | reader!.onfail!(reader!.error!) 33 | } 34 | 35 | static readAsArrayBuffer(blob: Blob): BlobReader { 36 | return new BlobReader(this.prototype.readAsArrayBuffer, blob) 37 | } 38 | static readAsBinaryString(blob: Blob): BlobReader { 39 | return new BlobReader(this.prototype.readAsBinaryString, blob) 40 | } 41 | static readAsText(blob: Blob): BlobReader { 42 | return new BlobReader(this.prototype.readAsText, blob) 43 | } 44 | static readAsDataURL(blob: Blob): BlobReader { 45 | return new BlobReader(this.prototype.readAsDataURL, blob) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/content.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AgentInbox, 3 | AgentMessage, 4 | UIInbox, 5 | SimilarResponse, 6 | SimilarRequest, 7 | Display, 8 | HoveredLink, 9 | SelectionChange, 10 | } from './mailbox' 11 | import * as Protocol from './protocol' 12 | import { Program, Context } from './program' 13 | // No idea why just `from 'lit-html' does not seem to work here. 14 | import { html, renderView, nothing, View, ViewDriver, Viewer } from './view/html' 15 | import { md } from './remark' 16 | import { map } from './iterable' 17 | import { send, request } from './runtime' 18 | import * as scanner from './scanner' 19 | import * as Siblinks from './siblinks' 20 | import * as Backlinks from './backlinks' 21 | import * as Simlinks from './simlinks' 22 | import * as Thumb from './thumb' 23 | import * as URL from './url' 24 | import { Mode } from './mode' 25 | import { getSelectionTooltipRect, resolveRect } from './dom/selection' 26 | 27 | const onUIMessage = (message: MessageEvent) => { 28 | switch (message.type) { 29 | case 'close': { 30 | return close() 31 | } 32 | } 33 | } 34 | 35 | const close = () => { 36 | const view = document.querySelector('#xcrpt') 37 | if (view) { 38 | view.remove() 39 | } 40 | } 41 | 42 | type Model = { 43 | mode: Mode 44 | display: Display 45 | 46 | siblinks: Siblinks.Model 47 | resource: null | Protocol.Resource 48 | simlinks: Simlinks.Model 49 | } 50 | 51 | type Message = AgentInbox | AgentMessage 52 | type Address = { tabId: number; frameId: number } 53 | 54 | const init = (): [Model, Promise] => { 55 | return [ 56 | { 57 | mode: Mode.Enabled, 58 | display: Display.Backlinks, 59 | resource: null, 60 | siblinks: Siblinks.init(), 61 | simlinks: Simlinks.init(), 62 | }, 63 | lookup(), 64 | ] 65 | } 66 | 67 | const update = (message: Message, state: Model): [Model, null | Promise] => { 68 | switch (message.type) { 69 | case 'Enable': { 70 | return [enable(state), null] 71 | } 72 | case 'Disable': { 73 | return [disable(state), null] 74 | } 75 | case 'Toggle': { 76 | return [toggle(state), null] 77 | } 78 | case 'Hide': { 79 | return [hide(state), null] 80 | } 81 | case 'Show': { 82 | return [show(state, message.show), null] 83 | } 84 | case 'InspectLinksRequest': { 85 | return [state, scan()] 86 | } 87 | case 'InspectLinksResponse': { 88 | return [inspectLocalLinks(state, message.resource), null] 89 | } 90 | case 'OpenRequest': { 91 | return [state, open(message.url)] 92 | } 93 | case 'OpenResponse': { 94 | return [state, null] 95 | } 96 | case 'LookupResponse': { 97 | return [setMetadata(state, message.resource), ingest()] 98 | } 99 | case 'IngestResponse': { 100 | return [setIngested(state, message.ingest), null] 101 | } 102 | case 'LinkHover': { 103 | return [setHoveredLink(state, message.link), null] 104 | } 105 | case 'SelectionChange': { 106 | return updateSimlinks(state, message) 107 | } 108 | case 'SimilarResponse': { 109 | return updateSimlinks(state, message) 110 | } 111 | } 112 | } 113 | 114 | const setHoveredLink = (state: Model, link: HoveredLink | null) => { 115 | return { ...state, siblinks: Siblinks.hover(state.siblinks, link) } 116 | } 117 | 118 | const setIngested = (state: Model, ingested: Protocol.Ingest) => { 119 | return { ...state, siblinks: Siblinks.ingested(state.siblinks, ingested) } 120 | } 121 | 122 | const setMetadata = (state: Model, resource: Protocol.Resource) => { 123 | return { ...state, resource } 124 | } 125 | 126 | const inspectLocalLinks = (state: Model, resource: Protocol.Resource) => { 127 | return { ...state, mode: Mode.Active, resource } 128 | } 129 | 130 | const updateSimlinks = ( 131 | state: Model, 132 | message: SimilarResponse | SelectionChange 133 | ): [Model, null | Promise] => { 134 | const [simlinks, fx] = Simlinks.update(message, state.simlinks) 135 | return [{ ...state, simlinks }, fx] 136 | } 137 | 138 | const ingest = async (): Promise => { 139 | await loaded(document) 140 | const resource = scanner.read(document) 141 | const response = await request({ type: 'IngestRequest', resource }) 142 | return response 143 | } 144 | 145 | const loaded = (document: Document) => { 146 | if (document.readyState !== 'complete') { 147 | return once(document.defaultView!, 'load') 148 | } 149 | } 150 | 151 | const once = (target: Node | Window, type: string) => 152 | new Promise((resolve) => target.addEventListener(type, resolve, { once: true })) 153 | 154 | const lookup = async (): Promise => { 155 | const lookupURL = URL.from(location.href, { hash: '' }) 156 | const response = await request({ type: 'LookupRequest', lookup: location.href }) 157 | return response 158 | } 159 | 160 | const scan = async (): Promise => { 161 | await loaded(document) 162 | const resource = scanner.read(document) 163 | return { type: 'InspectLinksResponse', resource: toOutput(resource) } 164 | } 165 | 166 | const toOutput = (input: Protocol.InputResource): Protocol.Resource => { 167 | const info = { 168 | cid: null, 169 | title: input.title, 170 | description: input.description, 171 | icon: input.icon, 172 | image: input.image, 173 | } 174 | 175 | const resource = { 176 | url: input.url, 177 | info, 178 | links: [], 179 | backLinks: !input.links 180 | ? [] 181 | : input.links.map( 182 | (link): Protocol.Link => ({ 183 | kind: link.kind, 184 | name: link.name, 185 | title: link.title, 186 | fragment: link.referrerFragment, 187 | location: link.referrerLocation, 188 | identifier: '', 189 | target: { 190 | url: link.targetURL, 191 | }, 192 | referrer: { 193 | url: input.url, 194 | info, 195 | links: [], 196 | backLinks: [], 197 | tags: [], 198 | }, 199 | }) 200 | ), 201 | tags: input.tags 202 | ? input.tags.map((tag) => ({ 203 | name: tag.name, 204 | fragment: tag.targetFragment, 205 | location: tag.targetLocation, 206 | target: { url: input.url }, 207 | })) 208 | : [], 209 | } 210 | 211 | return resource 212 | } 213 | 214 | const open = async (url: string): Promise => request({ type: 'OpenRequest', url }) 215 | 216 | class UIRequest { 217 | type: 'UIRequest' 218 | message: Request 219 | respond: (response: UIInbox) => void 220 | constructor(message: Request, respond: (response: UIInbox) => void) { 221 | this.respond = respond 222 | this.message = message 223 | this.type = 'UIRequest' 224 | } 225 | } 226 | 227 | const disable = (state: Model): Model => ({ ...state, mode: Mode.Disabled }) 228 | const enable = (state: Model) => { 229 | if (state.mode === Mode.Disabled) { 230 | return { ...state, mode: Mode.Enabled } 231 | } else { 232 | return state 233 | } 234 | } 235 | 236 | const show = (state: Model, display: Display): Model => { 237 | return { ...state, display, mode: Mode.Active } 238 | } 239 | 240 | const toggle = (state: Model): Model => { 241 | switch (state.mode) { 242 | case Mode.Disabled: 243 | return { ...state, display: Display.Backlinks } 244 | case Mode.Enabled: 245 | return { ...state, mode: Mode.Active, display: Display.Backlinks } 246 | case Mode.Active: 247 | return { ...state, mode: Mode.Enabled, display: Display.Backlinks } 248 | } 249 | } 250 | 251 | const hide = (state: Model): Model => { 252 | return { ...state, mode: Mode.Enabled } 253 | } 254 | 255 | const render = (context: Context) => { 256 | let ui = document.querySelector('double-dagger-ui') 257 | if (!ui) { 258 | const ui = }>( 259 | document.createElement('double-dagger-ui') 260 | ) 261 | let shadowRoot = ui.attachShadow({ mode: 'open' }) 262 | renderView(view(context.state), shadowRoot, { eventContext: context }) 263 | const target = document.documentElement.appendChild(ui) 264 | shadowRoot.addEventListener('click', context, { passive: true }) 265 | document.addEventListener('mouseover', context, { passive: true }) 266 | document.addEventListener('mouseout', context, { passive: true }) 267 | document.addEventListener('mouseup', context, { passive: true }) 268 | document.addEventListener('click', context) 269 | 270 | target.program = context 271 | } else { 272 | renderView(view(context.state), ui.shadowRoot!) 273 | } 274 | } 275 | 276 | const view = (state: Model) => 277 | html` 278 | 283 | 284 |
285 | 286 | ${Thumb.view(state) && nothing} 287 | 288 | ${Backlinks.view(state)} 289 | 290 | ${Siblinks.view(state.siblinks)} 291 | 292 | ${Simlinks.view(state.simlinks)} 293 |
294 | 295 | ${Siblinks.viewOverlay(state.siblinks)} 296 | 297 | ${Simlinks.viewOverlay(state.simlinks)} 298 | ` 299 | 300 | const onEvent = (event: Event): Message | null => { 301 | switch (event.type) { 302 | case 'click': { 303 | return onClick(event) 304 | } 305 | case 'mouseover': { 306 | return onMouseOver(event) 307 | } 308 | case 'mouseout': { 309 | return onMouseOut(event) 310 | } 311 | case 'mouseup': { 312 | return onSelectionChange(event) 313 | } 314 | default: { 315 | return null 316 | } 317 | } 318 | } 319 | 320 | const onClick = (event: MouseEvent): Message | null => { 321 | const target = event.target 322 | if (target.classList.contains('bubble')) { 323 | event.preventDefault() 324 | event.stopPropagation() 325 | if (target.classList.contains('siblinks')) { 326 | return { type: 'Show', show: Display.Siblinks } 327 | } 328 | if (target.classList.contains('simlinks')) { 329 | return { type: 'Show', show: Display.Simlinks } 330 | } 331 | if (target.classList.contains('backlinks')) { 332 | return { type: 'Show', show: Display.Backlinks } 333 | } 334 | } 335 | 336 | if (target.classList.contains('ksp-browser-siblinks')) { 337 | event.preventDefault() 338 | return { type: 'Show', show: Display.Siblinks } 339 | } 340 | 341 | if (target.localName === 'a') { 342 | const anchor = target 343 | if (!anchor.href.startsWith('http')) { 344 | event.preventDefault() 345 | return { type: 'OpenRequest', url: anchor.href } 346 | } else { 347 | return null 348 | } 349 | } 350 | 351 | if (document.body.contains(target) || target.classList.contains('frame')) { 352 | return { type: 'Hide' } 353 | } else { 354 | return null 355 | } 356 | 357 | return null 358 | } 359 | 360 | const onSelectionChange = (event: MouseEvent): Message | null => { 361 | const { timeStamp } = event 362 | const selection = document.getSelection() 363 | const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null 364 | const content = range ? range.toString().trim() : '' 365 | if (content != '') { 366 | const url = URL.from(document.URL, { hash: '' }).href 367 | // const { top, left, width, height } = range!.getBoundingClientRect() 368 | const rect = getSelectionTooltipRect(selection!) 369 | const id = timeStamp 370 | return { type: 'SelectionChange', data: { content, url, rect, id } } 371 | } else { 372 | return { type: 'SelectionChange', data: null } 373 | } 374 | } 375 | 376 | const onMouseOver = (event: MouseEvent): Message | null => { 377 | const target = event.target 378 | if (target.localName === 'a') { 379 | const anchor = target 380 | const { x, y } = event 381 | const bestRect = Array.from(anchor.getClientRects()).reduce((left, right) => 382 | distance(left, x, y) < distance(right, x, y) ? left : right 383 | ) 384 | 385 | const { top, left, height, width } = resolveRect(anchor, bestRect) 386 | 387 | const rect = { top, left, height, width } 388 | return { type: 'LinkHover', link: { url: anchor.href, rect } } 389 | } 390 | return null 391 | } 392 | 393 | const distance = ({ left, top, width, height }: DOMRect, x: number, y: number): number => { 394 | let dx = Math.min(Math.abs(left - x), Math.abs(left + width - x)) 395 | let dy = Math.min(Math.abs(top - y), Math.abs(top + height - x)) 396 | 397 | return dx + dy 398 | } 399 | 400 | const onMouseOut = (event: MouseEvent): Message | null => { 401 | const target = event.target 402 | if (target.localName === 'a') { 403 | const anchor = target 404 | return { type: 'LinkHover', link: null } 405 | } 406 | return null 407 | } 408 | 409 | const onload = async () => { 410 | const program = Program.ui( 411 | { 412 | init, 413 | update, 414 | onEvent, 415 | render, 416 | }, 417 | undefined, 418 | document.body 419 | ) 420 | 421 | const onunload = () => { 422 | chrome.runtime.onMessage.removeListener(onExtensionMessage) 423 | program.send({ type: 'Disable' }) 424 | } 425 | 426 | const onExtensionMessage = (message: AgentInbox) => program.send(message) 427 | chrome.runtime.onMessage.addListener(onExtensionMessage) 428 | } 429 | 430 | onload() 431 | -------------------------------------------------------------------------------- /src/data.ts: -------------------------------------------------------------------------------- 1 | import * as protocol from './protocol' 2 | import { GQLView, gql } from './gql' 3 | 4 | export class InputTag extends GQLView { 5 | toGQL() { 6 | const { data } = this 7 | return gql`{ 8 | name: ${data.name} 9 | targetFragment: ${data.name} 10 | targetLocation: ${data.targetLocation} 11 | }` 12 | } 13 | } 14 | 15 | export class InputLink extends GQLView { 16 | toGQL() { 17 | const { data } = this 18 | return gql`{ 19 | targetURL: ${data.targetURL} 20 | referrerFragment: ${data.referrerFragment} 21 | referrerLocation: ${data.referrerLocation} 22 | kind: ${gql(data.kind)} 23 | name: ${data.name} 24 | title: ${data.title} 25 | identifier: ${data.identifier} 26 | }` 27 | } 28 | } 29 | 30 | export class InputResource extends GQLView { 31 | toGQL() { 32 | const { data } = this 33 | return gql`{ 34 | url: ${data.url} 35 | cid: ${data.cid} 36 | title: ${data.title} 37 | description: ${data.description} 38 | links: ${data.links && data.links.map((link) => new InputLink(link))} 39 | tags: ${data.tags && data.tags.map((tag) => new InputTag(tag))} 40 | }` 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/dom/selection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Return the best position to show the tooltip for the selection. 3 | */ 4 | export const getSelectionTooltipRect = (selection: Selection): null | DOMRect => { 5 | const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null 6 | // return range ? getTextBoundingBoxes(range).pop() || null : null 7 | if (range) { 8 | const node = selection.focusNode! 9 | const rects = range.getClientRects() 10 | const direction = isSelectionBackwards(selection, range) ? -1 : 1 11 | const rect = resolveRect( 12 | node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement!, 13 | direction < 0 ? rects[0] : rects[rects.length - 1] 14 | ) 15 | 16 | if (direction > 0) { 17 | return rect 18 | } else { 19 | return new DOMRect(rect.left + rect.width, rect.top, rect.width * -1, rect.height) 20 | } 21 | } 22 | return null 23 | } 24 | 25 | /** 26 | * Returns true if the start point of a selection occurs after the end point, 27 | * in document order. 28 | */ 29 | function isSelectionBackwards(selection: Selection, range: Range) { 30 | if (selection.focusNode === selection.anchorNode) { 31 | return selection.focusOffset < selection.anchorOffset 32 | } 33 | 34 | return range.startContainer === selection.focusNode 35 | } 36 | 37 | export const resolveRect = (element: HTMLElement, rect: DOMRect): DOMRect => { 38 | const window = element.ownerDocument && element.ownerDocument.defaultView 39 | const { scrollY, scrollX } = window || { scrollX: 0, scrollY: 0 } 40 | let node: HTMLElement | null = element 41 | let { top, left, width, height } = rect 42 | while (node) { 43 | top += node.scrollTop || 0 44 | left += node.scrollLeft || 0 45 | node = node.offsetParent 46 | } 47 | return new DOMRect(left + scrollX, top + scrollY, width, height) 48 | } 49 | 50 | /** 51 | * Returns the bounding rectangles of non-whitespace text nodes in `range`. 52 | * 53 | * @param {Range} range 54 | * @return {Array} Array of bounding rects in viewport coordinates. 55 | */ 56 | export function getTextBoundingBoxes(range: Range): DOMRect[] { 57 | const whitespaceOnly = /^\s*$/ 58 | const rects: DOMRect[] = [] 59 | for (const node of nodesInRange(range)) { 60 | if (node.nodeType === Node.TEXT_NODE && !node.textContent!.match(whitespaceOnly)) { 61 | const nodeRange = node.ownerDocument!.createRange() 62 | nodeRange.selectNodeContents(node) 63 | if (node === range.startContainer) { 64 | nodeRange.setStart(node, range.startOffset) 65 | } 66 | if (node === range.endContainer) { 67 | nodeRange.setEnd(node, range.endOffset) 68 | } 69 | // If the range ends at the start of this text node or starts at the end 70 | // of this node then do not include it. 71 | if (!nodeRange.collapsed) { 72 | // Measure the range and translate from viewport to document coordinates 73 | const viewportRects = Array.from(nodeRange.getClientRects()) 74 | nodeRange.detach() 75 | rects.push(...viewportRects) 76 | } 77 | } 78 | } 79 | 80 | return rects 81 | } 82 | 83 | /** 84 | * Iterate over all Node(s) in `range` in document order. 85 | * 86 | * @param {Range} range 87 | */ 88 | export const nodesInRange = function* (range: Range) { 89 | const root = range.commonAncestorContainer 90 | 91 | // The `whatToShow`, `filter` and `expandEntityReferences` arguments are 92 | // mandatory in IE although optional according to the spec. 93 | const nodeIter = root.ownerDocument!.createNodeIterator( 94 | root, 95 | NodeFilter.SHOW_ALL, 96 | null /* filter */, 97 | // @ts-ignore 98 | false /* expandEntityReferences */ 99 | ) 100 | 101 | let currentNode 102 | while ((currentNode = nodeIter.nextNode())) { 103 | if (isNodeInRange(range, currentNode)) { 104 | yield currentNode 105 | } 106 | } 107 | } 108 | 109 | /** 110 | * Returns true if `node` lies within a range. 111 | * 112 | * This is a simplified version of `Range.isPointInRange()` for compatibility 113 | * with IE. 114 | * 115 | * @param {Range} range 116 | * @param {Node} node 117 | */ 118 | export function isNodeInRange(range: Range, node: Node) { 119 | if (node === range.startContainer || node === range.endContainer) { 120 | return true 121 | } 122 | 123 | const nodeRange = node.ownerDocument!.createRange() 124 | nodeRange.selectNode(node) 125 | const isAtOrBeforeStart = range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 126 | const isAtOrAfterEnd = range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0 127 | nodeRange.detach() 128 | return isAtOrBeforeStart && isAtOrAfterEnd 129 | } 130 | -------------------------------------------------------------------------------- /src/effect.ts: -------------------------------------------------------------------------------- 1 | export const debounce = void>( 2 | f: fn, 3 | delay: number = 0 4 | ): ((...args: Parameters) => void) => { 5 | let timer: null | NodeJS.Timeout = null 6 | return function (...args) { 7 | if (timer !== null) { 8 | clearTimeout(timer) 9 | } 10 | timer = setTimeout(f, delay, ...args) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/gql.ts: -------------------------------------------------------------------------------- 1 | type Variable = 2 | | GQL 3 | | string 4 | | number 5 | | boolean 6 | | null 7 | | GQL[] 8 | | string[] 9 | | number[] 10 | | boolean[] 11 | | null[] 12 | 13 | class GQL { 14 | static toGQLString(value: Variable) { 15 | return value instanceof GQL ? value.toGQLString() : JSON.stringify(value) 16 | } 17 | toGQLString() { 18 | return this.toString() 19 | } 20 | toJSON() { 21 | return this.toGQLString() 22 | } 23 | } 24 | 25 | class GQLTemplate extends GQL { 26 | template: TemplateStringsArray | string[] 27 | variables: Variable[] 28 | constructor(template: TemplateStringsArray | string[], variables: Variable[]) { 29 | super() 30 | this.template = template 31 | this.variables = variables 32 | } 33 | toGQLString(): string { 34 | const { template, variables } = this 35 | let out = `` 36 | let index = 0 37 | while (index < template.length) { 38 | out += template[index] 39 | if (index < variables.length) { 40 | const variable = variables[index] 41 | out += GQL.toGQLString(variable) 42 | } 43 | index++ 44 | } 45 | return out 46 | } 47 | toString(): string { 48 | return this.toGQLString() 49 | } 50 | } 51 | 52 | export class GQLView
extends GQL { 53 | data: a 54 | static toGQLString(data: a): string { 55 | return new this(data).toGQLString() 56 | } 57 | static toGQL(data: a): GQL { 58 | return new this(data).toGQL() 59 | } 60 | constructor(data: a) { 61 | super() 62 | this.data = data 63 | } 64 | toGQL(): GQL { 65 | throw new RangeError('Subclass supposed to implement this') 66 | } 67 | toGQLString() { 68 | return this.toGQL().toString() 69 | } 70 | toString() { 71 | return this.toGQLString() 72 | } 73 | } 74 | 75 | export const gql = ( 76 | template: TemplateStringsArray | string[] | string, 77 | ...variables: Variable[] 78 | ): GQL => new GQLTemplate(typeof template === 'string' ? [template] : template, variables) 79 | -------------------------------------------------------------------------------- /src/ingest.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/src/ingest.ts -------------------------------------------------------------------------------- /src/iterable.ts: -------------------------------------------------------------------------------- 1 | export const filter = function* (p: (item: a) => boolean, source: Iterable): Iterable { 2 | for (const item of source) { 3 | if (p(item)) { 4 | yield item 5 | } 6 | } 7 | } 8 | 9 | export const map = function* (f: (item: a) => b, source: Iterable): Iterable { 10 | for (const item of source) { 11 | yield f(item) 12 | } 13 | } 14 | 15 | export const reduce = ( 16 | reducer: (item: a, state: b) => b, 17 | state: b, 18 | items: Iterable 19 | ): b => { 20 | let result = state 21 | for (const item of items) { 22 | result = reducer(item, state) 23 | } 24 | return result 25 | } 26 | 27 | export const concat = function* (iterables: Iterable>): Iterable { 28 | for (const iterable of iterables) { 29 | for (const item of iterable) { 30 | yield item 31 | } 32 | } 33 | } 34 | 35 | export const take = function* (n: number, iterable: Iterable): Iterable { 36 | if (n > 0) { 37 | let count = 0 38 | for (const item of iterable) { 39 | yield item 40 | if (++count >= n) { 41 | break 42 | } 43 | } 44 | } 45 | } 46 | 47 | export const first = (iterable: Iterable, fallback: a): a => { 48 | for (const item of iterable) { 49 | return item 50 | } 51 | return fallback 52 | } 53 | -------------------------------------------------------------------------------- /src/links.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing, View } from './view/html' 2 | import { viewList } from './view/list' 3 | import { Link, Tag, Resource } from './protocol' 4 | import { map } from './iterable' 5 | import { md } from './remark' 6 | import { view as viewResource } from './view/resource' 7 | 8 | export const viewLinks = (links: Link[], title: string) => 9 | links.length === 0 10 | ? nothing 11 | : html`

${title}

12 | ${viewList(groupByReferrer(links), viewLinkGroup, ['link'])}` 13 | 14 | const groupByReferrer = (links: Link[]) => { 15 | const map = new Map() 16 | for (const link of links) { 17 | const list = map.get(link.referrer.url) 18 | if (!list) { 19 | map.set(link.referrer.url, [link]) 20 | } else { 21 | map.set(link.referrer.url, [link, ...list]) 22 | } 23 | } 24 | return map.values() 25 | } 26 | 27 | const viewLinkGroup = (links: Link[]) => 28 | html`
29 | ${viewReferrer(links[0])} 30 | ${viewList(links, viewLink, ['link'])} 31 |
` 32 | 33 | const viewReferrer = (link: Link) => viewResource(link.referrer) 34 | 35 | const viewTags = (tags: Tag[]) => 36 | tags.map(({ name }) => html`
${name}`) 37 | 38 | const viewLink = (link: Link) => 39 | html` 40 | ${viewTags(link.referrer.tags)} 41 | ${md(linkedFragment(link))} 42 | ` 43 | 44 | const linkedFragment = (link: Link): string => 45 | link.identifier 46 | ? `${link.fragment || ''}\n[${link.identifier}]:${link.referrer.url}` 47 | : link.fragment || '' 48 | 49 | const viewContext = (link: Link) => 50 | html`
51 | ${md(link.fragment || link.referrer.info.description)} 52 |
` 53 | 54 | const viewReference = (link: Link) => 55 | link.identifier == null || link.identifier === '' ? nothing : viewReferenceLinkTarget(link) 56 | 57 | const viewReferenceLinkTarget = (link: Link) => 58 | html`→ 59 | 60 | ${link.identifier} 61 | ` 62 | -------------------------------------------------------------------------------- /src/mailbox.ts: -------------------------------------------------------------------------------- 1 | import * as Protocol from './protocol' 2 | 3 | export type Disable = { type: 'Disable' } 4 | export type Enable = { type: 'Enable' } 5 | export type CloseRequest = { type: 'CloseRequest' } 6 | 7 | export type HideRequest = { 8 | type: 'Hide' 9 | } 10 | 11 | export type ToggleRequest = { 12 | type: 'Toggle' 13 | } 14 | 15 | export enum Display { 16 | Backlinks = 'backlinks', 17 | Siblinks = 'siblinks', 18 | Simlinks = 'simlinks', 19 | } 20 | 21 | export type ShowRequest = { 22 | type: 'Show' 23 | show: Display 24 | } 25 | 26 | export type Rect = { 27 | top: number 28 | left: number 29 | width: number 30 | height: number 31 | } 32 | 33 | export type HoveredLink = { 34 | url: string 35 | rect: Rect 36 | } 37 | 38 | export type LinkHover = { 39 | type: 'LinkHover' 40 | link: HoveredLink | null 41 | } 42 | 43 | export type SelectionChange = { 44 | type: 'SelectionChange' 45 | data: SelectionData | null 46 | } 47 | 48 | export type SelectionData = { 49 | url: string 50 | content: string 51 | id: number 52 | rect: Rect 53 | } 54 | 55 | export type InspectLinksRequest = { 56 | type: 'InspectLinksRequest' 57 | } 58 | 59 | export type InspectLinksResponse = { 60 | type: 'InspectLinksResponse' 61 | resource: Protocol.Resource 62 | } 63 | 64 | export type SimilarRequest = { 65 | type: 'SimilarRequest' 66 | id: number 67 | rect: Rect 68 | input: Protocol.InputSimilar 69 | } 70 | 71 | export type SimilarResponse = { 72 | type: 'SimilarResponse' 73 | id: number 74 | similar: Protocol.Simlinks 75 | } 76 | 77 | export type OpenRequest = { 78 | type: 'OpenRequest' 79 | url: string 80 | } 81 | 82 | export type OpenResponse = { 83 | type: 'OpenResponse' 84 | open: Protocol.Open 85 | } 86 | 87 | export type LookupResponse = { 88 | type: 'LookupResponse' 89 | resource: Protocol.Resource 90 | } 91 | 92 | export type LookupRequest = { 93 | type: 'LookupRequest' 94 | lookup: string 95 | } 96 | 97 | export type IngestRequest = { 98 | type: 'IngestRequest' 99 | resource: Protocol.InputResource 100 | } 101 | 102 | export type IngestResponse = { 103 | type: 'IngestResponse' 104 | ingest: Protocol.Ingest 105 | } 106 | 107 | export type TagsRequest = { 108 | type: 'TagsRequest' 109 | } 110 | 111 | export type TagsResponse = { 112 | type: 'TagsResponse' 113 | response: { data: { tags: Protocol.Tag[] } } 114 | } 115 | 116 | export type ExtensionInbox = 117 | | CloseRequest 118 | | LookupRequest 119 | | IngestRequest 120 | | TagsRequest 121 | | OpenRequest 122 | | SimilarRequest 123 | 124 | export type AgentInbox = 125 | | ToggleRequest 126 | | ShowRequest 127 | | HideRequest 128 | | LookupResponse 129 | | IngestResponse 130 | | OpenResponse 131 | | InspectLinksRequest 132 | | SimilarResponse 133 | 134 | export type AgentOwnInbox = 135 | | Enable 136 | | Disable 137 | | OpenRequest 138 | | InspectLinksResponse 139 | | LinkHover 140 | | SelectionChange 141 | 142 | export type AgentMessage = AgentInbox | AgentOwnInbox 143 | 144 | export type UIInbox = CloseRequest 145 | 146 | type Address = 147 | | { to: 'extension' } 148 | | { to: 'tab'; tabId: number } 149 | | { to: 'frame'; tabId: number; frameId: number } 150 | -------------------------------------------------------------------------------- /src/mode.ts: -------------------------------------------------------------------------------- 1 | export enum Mode { 2 | Active = 'active', 3 | Enabled = 'enabled', 4 | Disabled = 'disabled', 5 | } 6 | -------------------------------------------------------------------------------- /src/port.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/src/port.ts -------------------------------------------------------------------------------- /src/program.ts: -------------------------------------------------------------------------------- 1 | export class Program { 2 | state: model 3 | app: App 4 | target: HTMLElement 5 | 6 | static ui( 7 | app: App, 8 | options: config, 9 | target: HTMLElement 10 | ) { 11 | return new Program(target, options, app) 12 | } 13 | 14 | constructor(target: HTMLElement, options: config, app: App) { 15 | this.target = target 16 | this.app = app 17 | const [state, fx] = app.init(options) 18 | this.state = state 19 | this.app.render(this) 20 | 21 | if (fx) { 22 | this.wait(fx) 23 | } 24 | } 25 | handleEvent(event: Event) { 26 | const message = this.app.onEvent(event) 27 | if (message) { 28 | this.send(message) 29 | } 30 | } 31 | transact([state, fx]: [model, null | Promise]) { 32 | if (this.state !== state) { 33 | this.state = state 34 | this.app.render(this) 35 | } 36 | 37 | if (fx) { 38 | this.wait(fx) 39 | } 40 | this.state = state 41 | } 42 | send(input: message) { 43 | this.transact(this.app.update(input, this.state)) 44 | } 45 | async wait(fx: Promise) { 46 | const message = await fx 47 | if (message) { 48 | this.send(message) 49 | } 50 | } 51 | } 52 | 53 | export type App = { 54 | onEvent: (event: Event) => null | message 55 | init(options: config): [model, null | Promise] 56 | update(input: message, state: model): [model, null | Promise] 57 | render(context: Context): void 58 | } 59 | 60 | export interface Context { 61 | readonly target: HTMLElement 62 | readonly state: model 63 | handleEvent(event: Event): void 64 | send(payload: message): void 65 | } 66 | -------------------------------------------------------------------------------- /src/protocol.ts: -------------------------------------------------------------------------------- 1 | export enum LinkKind { 2 | INLINE = 'INLINE', 3 | REFERENCE = 'REFERENCE', 4 | } 5 | 6 | export type ResourceInfo = { 7 | title: string 8 | description: string 9 | cid: Option 10 | icon: Option 11 | image: Option 12 | } 13 | 14 | export type Resource = { 15 | url: string 16 | info: ResourceInfo 17 | links: Link[] 18 | backLinks: Link[] 19 | tags: Tag[] 20 | } 21 | 22 | export type Link = { 23 | kind: LinkKind 24 | name: string 25 | title: string 26 | 27 | fragment: string | null | void 28 | location: string | null | void 29 | 30 | identifier: null | void | string 31 | target: Resource 32 | referrer: Resource 33 | } 34 | 35 | export type Tag = { 36 | name: string 37 | fragment: string | null | void 38 | location: string | null | void 39 | target: Resource 40 | } 41 | 42 | export type Simlinks = { 43 | keywords: string[] 44 | similar: Simlink[] 45 | } 46 | 47 | export type Simlink = { 48 | resource: Resource 49 | score: number 50 | } 51 | 52 | export type InputSimilar = { 53 | content: string 54 | url: string 55 | } 56 | 57 | export type Option = null | t 58 | 59 | export type Vec = t[] 60 | 61 | export type InputResource = { 62 | url: string 63 | cid: Option 64 | icon: Option 65 | image: Option 66 | content: Option 67 | title: string 68 | description: string 69 | links: Option> 70 | tags: Option> 71 | } 72 | 73 | export type InputLink = { 74 | targetURL: string 75 | 76 | referrerFragment: Option 77 | referrerLocation: Option 78 | 79 | kind: LinkKind 80 | name: string 81 | title: string 82 | identifier: Option 83 | } 84 | 85 | export type InputTag = { 86 | name: string 87 | targetFragment: Option 88 | targetLocation: Option 89 | } 90 | 91 | export type Open = { 92 | openOk: boolean 93 | closeOk: boolean 94 | code: Option 95 | } 96 | 97 | export type Ingest = { 98 | url: string 99 | sibLinks: SibLink[] 100 | } 101 | 102 | export type SibLink = { 103 | target: { 104 | url: string 105 | backLinks: Link[] 106 | tags: Tag[] 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/remark.ts: -------------------------------------------------------------------------------- 1 | import remark from 'remark' 2 | import relink from 'remark-inline-links' 3 | import rehype from 'remark-rehype' 4 | import raw from 'rehype-raw' 5 | import stringify from 'rehype-stringify' 6 | import { unsafeHTML } from '../node_modules/lit-html/directives/unsafe-html' 7 | 8 | const transform = remark() 9 | .use(relink, { unlinkBrokenLinks: true }) 10 | .use(rehype, { allowDangerousHtml: true }) 11 | .use(raw) 12 | .use(stringify) 13 | 14 | export const md = (content: string) => unsafeHTML(transform.processSync(content.trim()).toString()) 15 | -------------------------------------------------------------------------------- /src/runtime.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionInbox, AgentInbox } from 'mailbox' 2 | 3 | export const send = async (message: ExtensionInbox) => { 4 | chrome.runtime.sendMessage(message) 5 | return null 6 | } 7 | 8 | export const request = (message: ExtensionInbox): Promise => 9 | new Promise((resolve, reject) => { 10 | chrome.runtime.sendMessage(message, (response) => { 11 | if (response) { 12 | resolve(response) 13 | } else if (chrome.runtime.lastError) { 14 | const error = chrome.runtime.lastError 15 | if (!error) { 16 | resolve(response) 17 | } else { 18 | reject(error) 19 | } 20 | } 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /src/scanner.ts: -------------------------------------------------------------------------------- 1 | import { clipSummary } from './scraper' 2 | import * as protocol from './protocol' 3 | import Readability from 'readability/Readability' 4 | import { turndown } from './turn-down' 5 | import * as URL from './url' 6 | 7 | export const read = (target: HTMLDocument): protocol.InputResource => { 8 | const { title, description, icon, image } = clipSummary(document) 9 | 10 | return { 11 | url: URL.from(document.URL, { hash: '' }).href, 12 | links: [...readLinks(document)], 13 | content: readContent(document), 14 | cid: null, 15 | icon, 16 | image, 17 | title, 18 | description, 19 | tags: [], 20 | } 21 | } 22 | 23 | const readContent = (document: HTMLDocument): string => { 24 | let source = document.cloneNode(true) 25 | var article = new Readability(source).parse() 26 | return turndown(article.content) 27 | } 28 | 29 | const IGNORED_PROTOCOLS = new Set([ 30 | 'javascript:', 31 | 'data:', 32 | 'blob:', 33 | 'about:', 34 | 'moz-extension:', 35 | 'chrome:', 36 | 'about:', 37 | 'resource:', 38 | 'view-source:', 39 | 'chrome-extension:', 40 | ]) 41 | 42 | const readLinks = function* (document: HTMLDocument): Iterable { 43 | const baseURL = URL.from(document.URL, { hash: '', search: '' }) 44 | 45 | for (const element of scanLinks(document)) { 46 | const targetURL = URL.parse(element.href, baseURL) 47 | yield { 48 | kind: protocol.LinkKind.INLINE, 49 | targetURL: targetURL.href, 50 | identifier: null, 51 | name: element.text.trim(), 52 | title: element.title, 53 | referrerFragment: readLinkContext(element), 54 | referrerLocation: null, 55 | } 56 | } 57 | } 58 | 59 | export const scanLinks = function* (document: HTMLDocument): Iterable { 60 | const baseURL = URL.from(document.URL, { hash: '', search: '' }) 61 | const elements: Iterable = document.body.querySelectorAll('a[href]') 62 | 63 | for (const element of elements) { 64 | const targetURL = URL.parse(element.href, baseURL) 65 | // If target URL is not on the ignored protocol list and 66 | // it is not the same url but with different query params and hashes 67 | // include this into read links. 68 | if ( 69 | !IGNORED_PROTOCOLS.has(targetURL.protocol) && 70 | (baseURL.origin !== targetURL.origin || baseURL.pathname != targetURL.pathname) 71 | ) { 72 | yield element 73 | } 74 | } 75 | } 76 | 77 | const isSameDocumentURL = (target: URL, source: URL) => 78 | target.origin === source.origin && target.pathname === source.pathname 79 | 80 | const readLinkContext = (link: HTMLAnchorElement): string => { 81 | let text = `[${link.text}](${link.href} ${JSON.stringify(link.title)})` 82 | 83 | for (const element of iterateNodes(link, previousInlineSibling, getInlineParent)) { 84 | const textContent = element.textContent || '' 85 | text = `${content(element)}${text}` 86 | } 87 | 88 | for (const element of iterateNodes(link, nextInlineSibling, getInlineParent)) { 89 | text = `${text}${content(element)}` 90 | } 91 | 92 | // get rid duplicate whitespaces 93 | return text.trim().replace(/\s{2}/g, '') 94 | } 95 | 96 | const content = (node: Node) => { 97 | const text = node.textContent || '' 98 | return (node).tagName === 'CODE' && text.length > 0 ? `\`${text}\`` : text 99 | } 100 | 101 | const INLINE_ELEMENTS = new Set([ 102 | 'A', 103 | 'ABBR', 104 | 'ACRONYM', 105 | 'AUDIO', 106 | 'B', 107 | 'BDI', 108 | 'BDO', 109 | 'BIG', 110 | 'BR', 111 | 'BUTTON', 112 | 'CANVAS', 113 | 'CITE', 114 | 'CODE', 115 | 'DATA', 116 | 'DATALIST', 117 | 'DEL', 118 | 'DFN', 119 | 'EM', 120 | 'EMBED', 121 | 'I', 122 | 'IFRAME', 123 | 'IMG', 124 | 'INPUT', 125 | 'INS', 126 | 'KBD', 127 | 'LABEL', 128 | 'MAP', 129 | 'MARK', 130 | 'METER', 131 | 'NOSCRIPT', 132 | 'OBJECT', 133 | 'OUTPUT', 134 | 'PICTURE', 135 | 'PROGRESS', 136 | 'Q', 137 | 'RUBY', 138 | 'S', 139 | 'SAMP', 140 | 'SCRIPT', 141 | 'SELECT', 142 | 'SLOT', 143 | 'SMALL', 144 | 'SPAN', 145 | 'STRONG', 146 | 'SUB', 147 | 'SUP', 148 | 'SVG', 149 | 'TEMPLATE', 150 | 'TEXTAREA', 151 | 'TIME', 152 | 'U', 153 | 'TT', 154 | 'VAR', 155 | 'VIDEO', 156 | 'WBR', 157 | ]) 158 | const getInlineParent = (node: Node): Node | null => toInlineNode(node.parentElement) 159 | 160 | const previousInlineSibling = (node: Node) => toInlineNode(node.previousSibling) 161 | const nextInlineSibling = (node: Node) => toInlineNode(node.nextSibling) 162 | const toInlineNode = (node: null | Node): Node | null => { 163 | if (node && (node.nodeType === Node.TEXT_NODE || INLINE_ELEMENTS.has((node).tagName))) { 164 | return node 165 | } else { 166 | return null 167 | } 168 | } 169 | 170 | let iterateNodes = function* ( 171 | node: Node, 172 | next: (node: Node) => Node | null, 173 | parent: (node: Node) => Node | null 174 | ): Iterable { 175 | let target: Node | null = node 176 | while (target) { 177 | let sibling = next(target) 178 | while (sibling) { 179 | yield sibling 180 | sibling = next(sibling) 181 | } 182 | target = parent(target) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/scraper.ts: -------------------------------------------------------------------------------- 1 | import { first, reduce, concat, filter, map, take } from './iterable' 2 | 3 | export type ScrapeData = { 4 | url: string 5 | icon: string | null 6 | image: string | null 7 | title: string 8 | description: string 9 | name: string 10 | } 11 | 12 | const baseURL = (spec: string): string => { 13 | var url = new URL(spec) 14 | url.search = '' 15 | url.hash = '' 16 | var href = url.href 17 | return href.endsWith('/') ? href : href + '/' 18 | } 19 | 20 | const makeRelative = (url: string): string => './' + url.replace(/:\/\//g, '/') 21 | 22 | export const clipSummary = (document: Document): ScrapeData => { 23 | /* 24 | Pull structured content out of the DOM. 25 | - Hero images 26 | - Title 27 | - Summary 28 | - Site name 29 | - Article content 30 | Things we can use: 31 | - `` 32 | - meta description 33 | - Twitter card meta tags 34 | - Facebook Open Graph tags 35 | - Win8 Tile meta tags 36 | - meta description 37 | - Search snippet things like schema.org 38 | - microformats 39 | https://github.com/mozilla/readability 40 | http://schema.org/CreativeWork 41 | https://dev.twitter.com/cards/markup 42 | https://developers.facebook.com/docs/sharing/webmasters#markup 43 | https://developer.apple.com/library/ios/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html 44 | http://blogs.msdn.com/b/ie/archive/2014/05/21/support-a-live-tile-for-your-website-on-windows-and-windows-phone-8-1.aspx 45 | http://www.oembed.com/ 46 | https://developer.chrome.com/multidevice/android/installtohomescreen 47 | */ 48 | 49 | // Utils 50 | // ----------------------------------------------------------------------------- 51 | 52 | // Scraping and content scoring helpers 53 | // ----------------------------------------------------------------------------- 54 | 55 | // @TODO need some methods for scaling and cropping images. 56 | 57 | return { 58 | url: document.URL, 59 | icon: scrapeIcon(document.documentElement), 60 | image: scrapeImage(document.documentElement), 61 | title: scrapeTitle(document.documentElement, '').trim(), 62 | description: scrapeDescription(document.documentElement, '').trim(), 63 | name: scrapeSiteName(document.documentElement, '').trim(), 64 | } 65 | } 66 | 67 | // Function 68 | 69 | const identity = <a>(x: a): a => x 70 | 71 | // Iterables 72 | 73 | // DOM 74 | 75 | const query = function* <a>( 76 | selector: string, 77 | decode: (el: Element) => null | a, 78 | root: Document | Element | DocumentFragment 79 | ): Iterable<a> { 80 | const elements = [...(<any>root.querySelectorAll(selector))] 81 | for (const element of elements) { 82 | const data = decode(element) 83 | if (data != null) { 84 | yield data 85 | } 86 | } 87 | } 88 | 89 | const getText = ({ textContent }: Element): string => (textContent || '').trim() 90 | 91 | const getContent = (metaEl: Element): string | null => 92 | !(metaEl instanceof HTMLMetaElement) ? null : metaEl.content == '' ? null : metaEl.content.trim() 93 | 94 | const getSrc = (imgEl: HTMLImageElement): string => imgEl.src 95 | 96 | const getHref = (linkEl: Element): string | null => 97 | linkEl instanceof HTMLLinkElement ? linkEl.href : null 98 | 99 | // Does element match a particular tag name? 100 | const matchesTag = (el: Element, pattern: RegExp) => el.tagName.search(pattern) !== -1 101 | 102 | const matchesClass = (el: Element, pattern: RegExp) => el.className.search(pattern) !== -1 103 | 104 | // Scraper 105 | 106 | // Score the content-y-ness of a string. Note that this is an imperfect score 107 | // and you'll be better off if you combine it with other heuristics like 108 | // element classname, etc. 109 | const scoreContentyness = (text: string) => { 110 | // If paragraph is less than 25 characters, don't count it. 111 | if (text.length < 25) return 0 112 | 113 | // Ok, we've weeded out the no-good cases. Start score at one. 114 | var score = 1 115 | 116 | // Add points for any commas within. 117 | score = score + text.split(',').length 118 | 119 | // For every 100 characters in this paragraph, add another point. 120 | // Up to 3 points. 121 | score = score + Math.min(Math.floor(text.length / 100), 3) 122 | 123 | return score 124 | } 125 | 126 | // Score a child element to find out how "content-y" it is. 127 | // A score is determined by things like number of commas, etc. 128 | // Maybe eventually link density. 129 | const scoreElContentyness = (el: Element) => scoreContentyness(getText(el)) 130 | const isSufficientlyContenty = (el: Element, base = 3) => scoreElContentyness(el) > base 131 | 132 | const UNLIKELY_CONTENT_CLASSNAMES = /date|social|community|remark|discuss|disqus|e[\-]?mail|rss|print|extra|share|login|sign|reply|combx|comment|com-|contact|header|menu|foot|footer|footnote|masthead|media|meta|outbrain|promo|related|scroll|shoutbox|sidebar|sponsor|shopping|tags|tool|widget|sidebar|sponsor|ad-break|agegate|pagination|pager|popup|tweet|twitter/i 133 | 134 | const isUnlikelyCandidate = (el: Element) => matchesClass(el, UNLIKELY_CONTENT_CLASSNAMES) 135 | 136 | const countWords = (text: string) => text.split(/\s/).length 137 | 138 | // Is text long enough to be content? 139 | const isSufficientlyLong = (text: string) => text.length > 25 140 | const isTextSufficientlyLong = (el: Element) => isSufficientlyLong(getText(el)) 141 | const isntEmpty = (text: string) => text != '' 142 | 143 | const getElTextLength = (el: Element) => getText(el).length 144 | const sum = (a: number, b: number) => a + b 145 | 146 | // Calculat the density of links in content. 147 | const calcLinkDensity = (el: Element) => { 148 | const linkSizes = query('a', getElTextLength, el) 149 | const linkSize = reduce(sum, 0, linkSizes) 150 | const textSize = getElTextLength(el) 151 | 152 | return linkSize / textSize 153 | } 154 | 155 | // Is the link density of this element high? 156 | const isHighLinkDensity = (el: Element) => calcLinkDensity(el) > 0.5 157 | 158 | // Extract a clean title from text that has been littered with separator 159 | // garbage. 160 | const cleanTitle = (text: string): string => { 161 | var title = text 162 | if (text.match(/\s[\|\-:]\s/)) { 163 | title = text.replace(/(.*)[\|\-:] .*/gi, '$1') 164 | 165 | if (countWords(title) < 3) { 166 | title = text.replace(/[^\|\-]*[\|\-](.*)/gi, '$1') 167 | } 168 | 169 | // Fall back to title if word count is too short. 170 | if (countWords(title) < 5) { 171 | title = text 172 | } 173 | } 174 | 175 | // Trim spaces. 176 | return title.trim() 177 | } 178 | 179 | export const getCleanText = (el: Element) => cleanTitle(getText(el)) 180 | 181 | // Content scrapers 182 | // ----------------------------------------------------------------------------- 183 | 184 | // Find a good title within page. 185 | // Usage: `scrapeTitle(htmlEl, 'Untitled')`. 186 | const scrapeTitle = (el: Element, fallback = 'Untitled'): string => { 187 | const candidates = concat([ 188 | query('meta[property="og:title"], meta[name="twitter:title"]', getContent, el), 189 | 190 | // Query hentry Microformats. Note that we just grab the blog title, 191 | // even on a blog listing page. You're going to associate the first title 192 | // with the identity of the page because it's the first thing you see on 193 | // the page when it loads. 194 | query('.entry-title, .h-entry .p-name', getText, el), 195 | // @TODO look at http://schema.org/Article `[itemprop=headline]` 196 | query('title', getCleanText, el), 197 | // If worst comes to worst, fall back on headings. 198 | query('h1, h2, h3', getText, el), 199 | [fallback], 200 | ]) 201 | 202 | return first(candidates, fallback) 203 | } 204 | 205 | const scrapeDescriptionFromContent = (pageEl: Element, fallback: string) => { 206 | // Query for all paragraphs on the page. 207 | // Trim down paragraphs to the ones we deem likely to be content. 208 | // Then map to `textContent`. 209 | 210 | const paragraphs = query('p', identity, pageEl) 211 | 212 | const qualified = filter(isQualifiedDescription, paragraphs) 213 | return map(getText, qualified) 214 | } 215 | 216 | export const isQualifiedDescription = (p: Element) => { 217 | const qualified = 218 | !isUnlikelyCandidate(p) && 219 | isTextSufficientlyLong(p) && 220 | !isHighLinkDensity(p) && 221 | isSufficientlyContenty(p) 222 | 223 | return qualified 224 | } 225 | 226 | // Find a good description for the page. 227 | // Usage: `scrapeDescription(htmlEl, '')`. 228 | const scrapeDescription = (el: Element, fallback: string) => { 229 | const candidates = concat([ 230 | // Prefer social media descriptions to `meta[name=description]` because they 231 | // are curated for readers, not search bots. 232 | query('meta[name="twitter:description"]', getContent, el), 233 | query('meta[property="og:description"]', getContent, el), 234 | // Scrape hentry Microformat description. 235 | query('.entry-summary, .h-entry .p-summary', getText, el), 236 | // @TODO process description to remove garbage from descriptions. 237 | query('meta[name=description]', getContent, el), 238 | // @TODO look at http://schema.org/Article `[itemprop=description]` 239 | scrapeDescriptionFromContent(el, fallback), 240 | ]) 241 | 242 | return first(candidates, fallback) 243 | } 244 | 245 | // You probably want to use the base URL as fallback. 246 | const scrapeSiteName = (el: Element, fallback: string) => { 247 | const candidates = concat([ 248 | // Prefer the standard meta tag. 249 | query('meta[name="application-name"]', getContent, el), 250 | query('meta[property="og:site_name"]', getContent, el), 251 | // Note that this one is an `@name`. 252 | query('meta[name="twitter:site"]', getContent, el), 253 | [fallback], 254 | ]) 255 | 256 | return first(candidates, fallback) 257 | } 258 | 259 | const isImgSizeAtLeast = (imgEl: HTMLImageElement, w: number, h: number) => 260 | imgEl.naturalWidth > w && imgEl.naturalHeight > h 261 | 262 | const isImgHeroSize = (imgEl: HTMLImageElement) => isImgSizeAtLeast(imgEl, 480, 300) 263 | 264 | // Collect Twitter image urls from meta tags. 265 | // Returns an array of 1 or more Twitter img urls, or null. 266 | // See https://dev.twitter.com/cards/markup. 267 | const queryTwitterImgUrls = (pageEl: Document | Element | DocumentFragment) => 268 | query( 269 | ` 270 | meta[name="twitter:image"], 271 | meta[name="twitter:image:src"], 272 | meta[name="twitter:image0"], 273 | meta[name="twitter:image1"], 274 | meta[name="twitter:image2"], 275 | meta[name="twitter:image3"] 276 | `, 277 | getContent, 278 | pageEl 279 | ) 280 | 281 | // Collect Facebook Open Graph image meta tags. 282 | // Returns an aray of 0 or more meta elements. 283 | // These 2 meta tags are equivalent. If the first doesn't exist, look for 284 | // the second. 285 | // See https://developers.facebook.com/docs/sharing/webmasters#images. 286 | const queryOpenGraphImgUrls = (el: Document | Element | DocumentFragment) => 287 | query( 288 | ` 289 | meta[property="og:image"], 290 | meta[property="og:image:url"] 291 | `, 292 | getContent, 293 | el 294 | ) 295 | 296 | const findHeroImgUrls = ( 297 | pageEl: Document | Element | DocumentFragment, 298 | isQualified: (image: HTMLImageElement) => boolean = isImgHeroSize 299 | ) => { 300 | const candidates = <Iterable<HTMLImageElement>>query('img', identity, pageEl) 301 | const heroSized = filter(isQualified, candidates) 302 | const urls = map(getSrc, heroSized) 303 | 304 | // can be a lot of images we limit to 4 305 | return take(4, filter(isntEmpty, urls)) 306 | } 307 | 308 | // Scrape up to 4 featured images. 309 | // We favor meta tags like `twitter:image` and `og:image` because those are 310 | // hand-curated. If we don't them, we'll dig through the content ourselves. 311 | // Returns an array of image urls. 312 | // @TODO it might be better just to grab everything, then de-dupe URLs. 313 | const scrapeHeroImgUrls = (el: Document | Element | DocumentFragment) => { 314 | // Note that Facebook OpenGraph image queries are kept seperate from Twitter 315 | // image queries. This is to prevent duplicates when sites include both. 316 | // If we find Twitter first, we'll return it and never look for Facebook. 317 | // We'll favor Twitter image URLs, since there can be more than one. 318 | const all = concat([queryOpenGraphImgUrls(el), queryTwitterImgUrls(el), findHeroImgUrls(el)]) 319 | 320 | return all 321 | } 322 | 323 | const scrapeImage = (el: Document | Element | DocumentFragment) => 324 | first(scrapeHeroImgUrls(el), null) 325 | 326 | const scrapeIcons = (el: HTMLElement): Iterable<HTMLLinkElement> => 327 | query( 328 | ` 329 | link[rel="shortcut icon"], 330 | link[rel="apple-touch-icon"], 331 | link[rel="apple-touch-icon-precomposed"] 332 | link[rel="mask-icon"], 333 | link[rel="icon"] 334 | `, 335 | (link) => (link.tagName === 'LINK' ? <HTMLLinkElement>link : null), 336 | el 337 | ) 338 | 339 | const scrapeIcon = (el: HTMLElement): string | null => 340 | first( 341 | map(({ href }) => href, scrapeIcons(el)), 342 | null 343 | ) 344 | 345 | // If we have 4 or more images, we show 4 images in combination. 346 | // Otherwise, use the first featured image only. 347 | const isImgCombo = (imgUrls: string[]) => imgUrls.length > 3 348 | -------------------------------------------------------------------------------- /src/siblinks.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing, View, Viewer, ViewDriver, renderView } from './view/html' 2 | import { Link, Tag, Ingest, SibLink } from './protocol' 3 | import { viewLinks } from './links' 4 | import { HoveredLink } from './mailbox' 5 | import { scanLinks } from './scanner' 6 | import * as URL from './url' 7 | 8 | type Siblink = { links: Link[]; tags: Tag[] } 9 | type Siblinks = Map<string, Siblink> 10 | 11 | const isEqualLink = (left: HoveredLink, right: HoveredLink): boolean => { 12 | return left.url === right.url 13 | } 14 | 15 | enum Status { 16 | Over = 'over', 17 | Out = 'out', 18 | } 19 | 20 | type TargetLink = { 21 | status: Status 22 | link: HoveredLink 23 | } 24 | 25 | export type Model = { 26 | siblinks: null | Siblinks 27 | target: null | TargetLink 28 | } 29 | 30 | export const siblinksOf = ({ target, siblinks }: Model): Siblink | null => 31 | (siblinks && target && siblinks.get(target.link.url)) || null 32 | 33 | type ReadyState = { 34 | status: Status 35 | link: HoveredLink 36 | siblinks: Siblink 37 | } 38 | 39 | const read = (state: Model): ReadyState | null => { 40 | const siblinks = siblinksOf(state) 41 | const { target } = state 42 | if (target && siblinks) { 43 | const { link, status } = target 44 | return { link, siblinks, status } 45 | } else { 46 | return null 47 | } 48 | } 49 | 50 | export const init = (): Model => { 51 | return { target: null, siblinks: null } 52 | } 53 | 54 | export const ingested = (state: Model, { sibLinks }: Ingest): Model => { 55 | let url = document.URL 56 | let map = new Map() 57 | for (const { target } of sibLinks) { 58 | const links = target.backLinks.filter((link) => link.referrer.url !== url) 59 | const tags = target.tags 60 | if (links.length > 0 || tags.length > 0) { 61 | map.set(target.url, { links, tags }) 62 | } 63 | } 64 | 65 | return { 66 | ...state, 67 | siblinks: map, 68 | } 69 | } 70 | 71 | export const hover = (state: Model, link: HoveredLink | null): Model => { 72 | // If same link is hoverd do nothing 73 | if (link) { 74 | return { ...state, target: { link, status: Status.Over } } 75 | } else { 76 | const { target } = state 77 | if (target == null) { 78 | return state 79 | } else if (target.status !== Status.Out) { 80 | return { ...state, target: { ...target, status: Status.Out } } 81 | } else { 82 | return state 83 | } 84 | } 85 | } 86 | 87 | export const viewSidebar = (state: Model): View => { 88 | const siblinks = siblinksOf(state) 89 | const links = siblinks ? siblinks.links : [] 90 | const { target } = state 91 | const mode = siblinks && target && target.status === Status.Over ? 'active' : 'disabled' 92 | return html`<aside class="panel siblinks"> 93 | ${viewLinks(links, 'Siblinks')} 94 | </aside>` 95 | } 96 | 97 | export const viewTooltip = (state: Model): View => { 98 | const data = read(state) 99 | if (data) { 100 | return viewActiveTooltip(data) 101 | } else { 102 | return viewInactiveTooltip() 103 | } 104 | } 105 | 106 | const viewInactiveTooltip = (): View => nothing 107 | 108 | const viewActiveTooltip = ({ status, link: { rect }, siblinks }: ReadyState): View => 109 | html`<dialog 110 | class="tooltip siblinks" 111 | ?open=${status === Status.Over} 112 | style="top: ${rect.top + rect.height}px; left:${rect.left + rect.width}px;" 113 | > 114 | ${viewLinks(siblinks.links, 'Siblinks')} 115 | </dialog>` 116 | 117 | const viewBubble = (state: Model): View => { 118 | const data = read(state) 119 | return data ? showBubble(data) : hideBubble(state) 120 | } 121 | 122 | const hideBubble = (state: Model): View => nothing 123 | const showBubble = ({ link: { rect }, status, siblinks }: ReadyState): View => 124 | html`<button 125 | class="bubble siblinks ${rect.width < 0 ? 'backward' : 'forward'} ${status}" 126 | style="top:${rect.top}px; 127 | left:${rect.width < 0 ? rect.left : rect.left + rect.width}px; 128 | font-size:${rect.height}px; 129 | line-height:${rect.height}px" 130 | > 131 | <div class="summary">‡${siblinks.links.length}</div> 132 | <!-- details --> 133 | <div class="details">${viewSiblinks(siblinks)}</div> 134 | </button>` 135 | 136 | const viewLinkAnnotations = Viewer((state: Model) => (driver: ViewDriver) => { 137 | const { siblinks } = state 138 | if (siblinks && !driver.value) { 139 | driver.setValue(html`<!-- DONE -->`) 140 | for (const link of scanLinks(document)) { 141 | const url = URL.from(link.href, { hash: '' }).href 142 | const siblink = siblinks.get(url) 143 | if (siblink) { 144 | const fragment = document.createDocumentFragment() 145 | renderView(viewLinkAnnotation(siblink, url), fragment) 146 | link.parentElement!.insertBefore(fragment, link.nextSibling) 147 | } 148 | } 149 | } 150 | }) 151 | 152 | const viewLinkAnnotation = (siblink: Siblink, url: string): View => 153 | html`<sup class="ksp-browser-annotation"> 154 | <a class="ksp-browser-siblinks" href="${url}" data-siblinks=${siblink.links.length} 155 | >[‡${siblink.links.length}]</a 156 | > 157 | </sup>` 158 | 159 | export const view = (state: Model): View => viewSidebar(state) 160 | 161 | export const viewOverlay = (state: Model): View => viewBubble(state) 162 | const viewSiblinks = (siblinks: Siblink): View => viewLinks(siblinks.links, 'Siblinks') 163 | -------------------------------------------------------------------------------- /src/simlinks.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing, View } from './view/html' 2 | import { Simlinks, Simlink, InputSimilar } from './protocol' 3 | import { viewList } from './view/list' 4 | import { view as viewResource } from './view/resource' 5 | import { request } from './runtime' 6 | import { 7 | AgentInbox, 8 | AgentOwnInbox, 9 | SimilarResponse, 10 | SimilarRequest, 11 | Rect, 12 | SelectionChange, 13 | SelectionData, 14 | } from './mailbox' 15 | 16 | type Message = AgentInbox 17 | 18 | enum Status { 19 | Idle = 'idle', 20 | Pending = 'pending', 21 | Ready = 'ready', 22 | } 23 | 24 | type Idle = { status: Status.Idle; query: null; result: null } 25 | type Pending = { status: Status.Pending; query: Query; result: null } 26 | type Ready = { status: Status.Ready; query: Query; result: Simlinks } 27 | 28 | export type Query = { 29 | id: number 30 | input: InputSimilar 31 | rect: Rect 32 | } 33 | export type Model = Idle | Pending | Ready 34 | 35 | export const idle = (): Model => { 36 | return { status: Status.Idle, query: null, result: null } 37 | } 38 | 39 | export const init = idle 40 | 41 | export const update = ( 42 | message: SimilarResponse | SelectionChange, 43 | state: Model 44 | ): [Model, null | Promise<null | Message>] => { 45 | switch (message.type) { 46 | case 'SelectionChange': { 47 | return updateSelection(message.data, state) 48 | } 49 | case 'SimilarResponse': { 50 | return complete(message, state) 51 | } 52 | } 53 | } 54 | 55 | const updateSelection = ( 56 | data: SelectionData | null, 57 | state: Model 58 | ): [Model, null | Promise<null | Message>] => { 59 | if (data) { 60 | const { content, url, id, rect } = data 61 | return query({ input: { content, url }, id, rect }, state) 62 | } else { 63 | return [idle(), null] 64 | } 65 | } 66 | 67 | export const query = (query: Query, state: Model): [Model, null | Promise<null | Message>] => { 68 | switch (state.status) { 69 | case Status.Idle: { 70 | return [{ status: Status.Pending, query, result: null }, similar(query)] 71 | } 72 | case Status.Ready: 73 | case Status.Pending: { 74 | if (query.input.content.length < 4) { 75 | return [idle(), null] 76 | } else if (query.input.content != state.query.input.content) { 77 | return [{ status: Status.Pending, query, result: null }, similar(query)] 78 | } else { 79 | return [state, null] 80 | } 81 | } 82 | } 83 | } 84 | 85 | export const complete = ( 86 | message: SimilarResponse, 87 | state: Model 88 | ): [Model, null | Promise<null | Message>] => { 89 | if (state.status === Status.Pending && state.query.id === message.id) { 90 | return [{ status: Status.Ready, query: state.query, result: message.similar }, null] 91 | } else { 92 | return [state, null] 93 | } 94 | } 95 | 96 | const similar = async (query: Query): Promise<AgentInbox | null> => { 97 | const response = await request({ 98 | type: 'SimilarRequest', 99 | input: query.input, 100 | rect: query.rect, 101 | id: query.id, 102 | }) 103 | return response 104 | } 105 | 106 | const toReady = (state: Model): Ready | null => { 107 | switch (state.status) { 108 | case Status.Idle: 109 | case Status.Pending: 110 | return null 111 | case Status.Ready: 112 | return state 113 | } 114 | } 115 | 116 | export const view = (state: Model): View => viewSidebar(state) 117 | 118 | export const viewOverlay = (state: Model): View => viewBubble(state) 119 | 120 | const viewBubble = (state: Model): View => { 121 | const data = toReady(state) 122 | return data ? showBubble(data) : hideBubble(state) 123 | } 124 | 125 | const hideBubble = (state: Model): View => nothing 126 | const showBubble = ({ query: { rect }, status, result }: Ready): View => 127 | html`<button 128 | class="bubble simlinks ${rect.width < 0 ? 'backward' : 'forward'} ${status}" 129 | style="top:${rect.top}px; 130 | left:${rect.width < 0 ? rect.left : rect.left + rect.width}px; 131 | font-size:${rect.height}px; 132 | line-height:${rect.height}px;" 133 | > 134 | <div class="summary">‡${result.similar.length}</div> 135 | <!-- details --> 136 | <div class="details">${viewSimlinks(result.similar)}</div> 137 | </button>` 138 | 139 | const viewSidebar = (state: Model): View => 140 | html`<aside class="panel simlinks"> 141 | <h2 class="marked"><span>Simlinks</span></h2> 142 | ${viewQuery(state.query)} ${viewKeywords(state.result ? state.result.keywords : [])} 143 | ${viewSimlinks(state.result ? state.result.similar : [])} 144 | </aside> ` 145 | 146 | const viewTooltip = (state: Model): View => { 147 | switch (state.status) { 148 | case Status.Idle: 149 | case Status.Pending: 150 | return viewInactiveTooltip(state) 151 | case Status.Ready: 152 | return viewActiveTooltip(state) 153 | } 154 | } 155 | 156 | const viewInactiveTooltip = (state: Idle | Pending) => nothing 157 | 158 | const viewActiveTooltip = ({ query: { rect }, result }: Ready) => 159 | html`<dialog 160 | class="tooltip simlinks" 161 | style="top: ${rect.top + rect.height}px; left:${rect.width < 0 162 | ? rect.left 163 | : rect.left + rect.width}px;" 164 | > 165 | ${viewSimlinks(result.similar)} 166 | </dialog>` 167 | 168 | const viewQuery = (query: Query | null) => 169 | html`<div class="query"> 170 | <span class="name">similar</span><span class="input">${query ? query.input.content : ''}</span> 171 | </div>` 172 | 173 | const viewKeywords = (keywords: string[]) => viewList(keywords || [], viewKeyword, ['keyword']) 174 | const viewKeyword = (name: string) => html`<a href="#${name}" class="keyword">${name}</a>` 175 | 176 | const viewSimlinks = (entries: Simlink[]): View => 177 | viewList(entries.slice(0, 3), viewSimlink, ['simlink']) 178 | const viewSimlink = (entry: Simlink): View => viewResource(entry.resource) 179 | -------------------------------------------------------------------------------- /src/thumb.ts: -------------------------------------------------------------------------------- 1 | import { html, nothing, Template, TemplateResult } from '../node_modules/lit-html/lit-html' 2 | import { Resource } from './protocol' 3 | import { viewLinks } from './links' 4 | 5 | type Model = { 6 | resource: Resource | null 7 | } 8 | 9 | export const view = ({ resource }: Model) => { 10 | const mode = !resource ? 'hide' : isEmpty(resource) ? 'disabled' : 'show' 11 | return html`<button class="thumb ${mode}"></button>` 12 | } 13 | 14 | const isEmpty = ({ backLinks, tags }: Resource): boolean => { 15 | if (backLinks.length + tags.length > 0) { 16 | return false 17 | } else { 18 | return true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/turn-down.ts: -------------------------------------------------------------------------------- 1 | import { gfm } from 'turndown-plugin-gfm' 2 | import Turndown from 'turndown' 3 | 4 | const service = new Turndown({ 5 | headingStyle: 'atx', 6 | hr: '---', 7 | bulletListMarker: '-', 8 | codeBlockStyle: 'fenced', 9 | emDelimiter: '_', 10 | strongDelimiter: '**', 11 | linkStyle: 'referenced', 12 | linkReferenceStyle: 'collapsed', 13 | }) 14 | service.use(gfm) 15 | 16 | export const turndown = (input: string | HTMLElement): string => service.turndown(input) 17 | -------------------------------------------------------------------------------- /src/ui.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inkandswitch/ksp-browser/460fb9bfb7b079099e7d4c15eeea0bf3cee6f8f8/src/ui.ts -------------------------------------------------------------------------------- /src/url.ts: -------------------------------------------------------------------------------- 1 | type URLFields = { 2 | protocol?: string 3 | search?: string 4 | hash?: string 5 | host?: string 6 | hostname?: string 7 | username?: string 8 | password?: string 9 | pathname?: string 10 | port?: string 11 | } 12 | 13 | export const from = (source: string | URL, fields?: URLFields | void | null): URL => { 14 | const url = new URL(source.toString()) 15 | if (fields != null) { 16 | Object.assign(url, fields) 17 | } 18 | return url 19 | } 20 | 21 | export const parse = (input: string | URL, base?: string | URL | null | void): URL => 22 | new URL(input.toString(), base || undefined) 23 | -------------------------------------------------------------------------------- /src/view/html.ts: -------------------------------------------------------------------------------- 1 | import { 2 | html, 3 | nothing as empty, 4 | Template, 5 | TemplateResult, 6 | Part, 7 | directive, 8 | render as renderView, 9 | } from '../../node_modules/lit-html/lit-html' 10 | type View = TemplateResult 11 | type ViewDriver = Part 12 | const nothing = <View>empty 13 | const Viewer = directive 14 | export { html, nothing, View, ViewDriver, Viewer, renderView } 15 | -------------------------------------------------------------------------------- /src/view/list.ts: -------------------------------------------------------------------------------- 1 | import { View, html, nothing } from './html' 2 | import { map } from '../iterable' 3 | 4 | export const viewList = <a>( 5 | data: Iterable<a>, 6 | view: (data: a) => View, 7 | classNames: string[] 8 | ): View => 9 | html`<ul class="${classNames.join(' ')}"> 10 | ${map((a) => html`<li class="${classNames.join(' ')}">${view(a)}</li>`, data)} 11 | </ul>` 12 | -------------------------------------------------------------------------------- /src/view/resource.ts: -------------------------------------------------------------------------------- 1 | import { Resource } from '../protocol' 2 | import { html, View } from './html' 3 | import { md } from '../remark' 4 | 5 | export const getIconURL = (resource: Resource): string => 6 | resource.info.icon || chrome.extension.getURL('link-solid.svg') 7 | 8 | export const getImageURL = (resource: Resource): string => { 9 | const { image } = resource.info 10 | if (image) { 11 | return image 12 | } else { 13 | const url = new URL(resource.url) 14 | switch (url.protocol) { 15 | case 'file:': { 16 | const extension = url.pathname.slice(url.pathname.lastIndexOf('.')).toLowerCase() 17 | switch (extension) { 18 | case '.markdown': 19 | case '.md': { 20 | return chrome.extension.getURL('md-icon.svg') 21 | } 22 | default: { 23 | return chrome.extension.getURL('file-alt-solid.svg') 24 | } 25 | } 26 | } 27 | default: { 28 | return chrome.extension.getURL('website-icon.svg') 29 | } 30 | } 31 | } 32 | } 33 | 34 | export const view = (resource: Resource): View => 35 | html`<div class="card resource"> 36 | <div class="card-image"> 37 | <img class="image" src="${getImageURL(resource)}" alt="" /> 38 | </div> 39 | <div class="card-content"> 40 | <a class="title" target="_blank" title="${resource.info.title}" href="${resource.url}"> 41 | ${resource.info.title.trim()} 42 | </a> 43 | <div class="description">${md(resource.info.description)}</div> 44 | <a class="url" target="_blank" title="${resource.info.title}" href="${resource.url}"> 45 | <img class="site-icon" src="${getIconURL(resource)}" alt="" /> 46 | ${resource.url} 47 | </a> 48 | </div> 49 | </div>` 50 | -------------------------------------------------------------------------------- /src/view/util.ts: -------------------------------------------------------------------------------- 1 | import { View, html, nothing } from './html' 2 | 3 | type Rect = { top: number; left: number; height: number; width: number } 4 | 5 | export const debug = ({ top, left, height, width }: Rect): View => 6 | html`<div 7 | class="debug" 8 | style="top:${top}px;left:${left}px;height:${height}px;width:${Math.abs(width)}px" 9 | ></div>` 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "skipLibCheck": true, 4 | "strict": true, 5 | "noImplicitAny": true, 6 | "sourceMap": true, 7 | "noFallthroughCasesInSwitch": true, 8 | "lib": ["es2015", "es2017", "dom", "webworker"], 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "jsx": "react", 12 | "pretty": true, 13 | "target": "ES2015", 14 | "experimentalDecorators": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import * as webpack from 'webpack' 4 | import * as path from 'path' 5 | import CopyPlugin from 'copy-webpack-plugin' 6 | // @ts-ignore 7 | import TerserPlugin from 'terser-webpack-plugin' 8 | 9 | const srcDir = './src/' 10 | 11 | const config: webpack.Configuration = { 12 | mode: 'production', 13 | devtool: 'inline-source-map', 14 | entry: { 15 | background: path.join(__dirname, srcDir + 'background.ts'), 16 | content: path.join(__dirname, srcDir + 'content.ts'), 17 | ui: path.join(__dirname, srcDir + 'ui.ts'), 18 | }, 19 | output: { 20 | path: path.join(__dirname, 'dist/'), 21 | filename: '[name].js', 22 | }, 23 | optimization: { 24 | minimizer: [ 25 | new TerserPlugin({ 26 | terserOptions: { 27 | output: { ascii_only: true }, 28 | }, 29 | }), 30 | ], 31 | }, 32 | module: { 33 | rules: [ 34 | { 35 | test: /\.tsx?$/, 36 | use: 'ts-loader', 37 | exclude: /node_modules/, 38 | }, 39 | ], 40 | }, 41 | resolve: { 42 | extensions: ['.ts', '.tsx', '.js'], 43 | }, 44 | plugins: [new CopyPlugin([{ from: '.', to: '../dist' }], { context: 'public' })], 45 | } 46 | 47 | module.exports = config 48 | --------------------------------------------------------------------------------