MaxSem has submitted this change and it was merged.

Change subject: Commit extension boilerplate and existing code from github
......................................................................


Commit extension boilerplate and existing code from github

https://github.com/jdlrobson/AshCatchem

Change-Id: I82d787b4cfbd2c704db7680859c71e0e2d3f4c94
---
A .gitignore
A .jsbeautifyrc
A .jscsrc
A .jscsrctest.js
A .jshintignore
A .jshintrc
A .rubocop.yml
A .rubocop_todo.yml
A .svgo.yml
A Gather.alias.php
A Gather.php
A Gemfile
A Gemfile.lock
A Gruntfile.js
A README.txt
A extension.json
A i18n/en.json
A i18n/qqq.json
A images/icons/next.svg
A includes/Gather.hooks.php
A includes/Resources.php
A includes/models/Collection.php
A includes/models/CollectionsList.php
A includes/specials/SpecialGather.php
A includes/stores/CollectionStore.php
A includes/stores/WatchlistCollectionStore.php
A includes/views/CollectionItemCardView.php
A includes/views/CollectionView.php
A includes/views/CollectionsListItemCardView.php
A includes/views/CollectionsListView.php
A includes/views/UserNotFoundView.php
A includes/views/View.php
A package.json
A resources/ext.collections.styles/collections.less
A resources/ext.collections.styles/icons.less
A resources/ext.collections.styles/images/icons/next.svg
A tests/.htaccess
A tests/browser/.gitignore
38 files changed, 1,767 insertions(+), 0 deletions(-)

Approvals:
  MaxSem: Verified; Looks good to me, approved



diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..953e79e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+*~
+.*.swp
+/composer.phar
+node_modules/
+tmp/
+docs/
+composer.phar
+.DS_Store
+composer.lock
+tests/report
diff --git a/.jsbeautifyrc b/.jsbeautifyrc
new file mode 100644
index 0000000..cef4838
--- /dev/null
+++ b/.jsbeautifyrc
@@ -0,0 +1,18 @@
+{
+    "indent_level": 0,
+    "indent_with_tabs": true,
+    "preserve_newlines": true,
+    "max_preserve_newlines": 10,
+    "jslint_happy": false,
+    "space_after_anon_function": true,
+    "brace_style": "collapse",
+    "keep_array_indentation": false,
+    "keep_function_indentation": false,
+    "space_before_conditional": true,
+    "space_in_paren": true,
+    "break_chained_methods": false,
+    "eval_code": false,
+    "unescape_strings": false,
+    "wrap_line_length": 0,
+    "end_with_newline": true
+}
diff --git a/.jscsrc b/.jscsrc
new file mode 100644
index 0000000..f1c731b
--- /dev/null
+++ b/.jscsrc
@@ -0,0 +1,32 @@
+{
+  "preset": "wikimedia",
+  "requireMultipleVarDecl": "onevar",
+  "requireLineBreakAfterVariableAssignment": true,
+  "disallowOperatorBeforeLineBreak": ["."],
+  "requireBlocksOnNewline": true,
+  "requireLineFeedAtFileEnd": true,
+  "requirePaddingNewLinesInObjects": true,
+  "disallowDanglingUnderscores": null,
+  "requireSpacesInsideParentheses": "all",
+  "disallowImplicitTypeConversion": ["numeric", "boolean", "binary", "string"],
+  "validateJSDoc": {
+    "checkParamNames": true,
+    "requireParamTypes": true,
+    "checkRedundantParams": true
+  },
+  "validateIndentation": "\t",
+  "excludeFiles": ["javascripts/externals/**/*.js", "javascripts/README.md"],
+  "additionalRules": [
+    "node_modules/jscs-jsdoc/lib/rules/*.js"
+  ],
+  "jsDoc": {
+    "checkParamNames": true,
+               "checkRedundantParams": true,
+    "enforceExistence": true,
+    "checkReturnTypes": true,
+    "checkRedundantReturns": true,
+    "requireReturnTypes": true,
+    "checkTypes": "capitalizedNativeCase",
+    "checkParamExistence": true
+  }
+}
diff --git a/.jscsrctest.js b/.jscsrctest.js
new file mode 100644
index 0000000..6435c26
--- /dev/null
+++ b/.jscsrctest.js
@@ -0,0 +1,6 @@
+var fs = require( 'fs' ),
+       config = JSON.parse( fs.readFileSync( '.jscsrc' ) );
+
+delete config.jsDoc;
+
+module.exports = exports = config;
diff --git a/.jshintignore b/.jshintignore
new file mode 100644
index 0000000..57e0837
--- /dev/null
+++ b/.jshintignore
@@ -0,0 +1,5 @@
+javascripts/externals
+javascripts/README.md
+tests/js/fixtures.js
+tests/externals
+styleguide-template
diff --git a/.jshintrc b/.jshintrc
new file mode 100644
index 0000000..df30cb2
--- /dev/null
+++ b/.jshintrc
@@ -0,0 +1,21 @@
+{
+       "globals": {
+               "console": true,
+               "jQuery": true,
+               "JsDiff": true,
+               "Hogan": true,
+               "QUnit": true,
+               "mw": true,
+               "OO": true
+       },
+
+       "browser": true,
+       "curly": true,
+       "eqeqeq": true,
+       "forin": false,
+       "onevar": true,
+       "trailing": true,
+       "undef" : true,
+       "unused": true,
+       "supernew": true
+}
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000..cc32da4
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1 @@
+inherit_from: .rubocop_todo.yml
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
new file mode 100644
index 0000000..78f2eba
--- /dev/null
+++ b/.rubocop_todo.yml
@@ -0,0 +1,39 @@
+# This configuration was generated by `rubocop --auto-gen-config`
+# on 2014-12-05 09:03:35 -0700 using RuboCop version 0.27.1.
+# The point is for the user to remove these configuration records
+# one by one as the offenses are removed from the code base.
+# Note that changes in the inspected code, or installation of new
+# versions of RuboCop, may require this file to be generated again.
+
+# Offense count: 9
+Lint/AmbiguousRegexpLiteral:
+  Enabled: false
+
+# Offense count: 1
+Lint/ParenthesesAsGroupedExpression:
+  Enabled: false
+
+# Offense count: 1
+# Configuration parameters: CountComments.
+Metrics/ClassLength:
+  Max: 109
+
+# Offense count: 54
+# Configuration parameters: AllowURI, URISchemes.
+Metrics/LineLength:
+  Max: 428
+
+# Offense count: 13
+Style/Documentation:
+  Enabled: false
+
+# Offense count: 1
+# Configuration parameters: AllowedVariables.
+Style/GlobalVars:
+  Enabled: false
+
+# Offense count: 1
+# Cop supports --auto-correct.
+Style/RedundantSelf:
+  Enabled: false
+
diff --git a/.svgo.yml b/.svgo.yml
new file mode 100644
index 0000000..52ae3ca
--- /dev/null
+++ b/.svgo.yml
@@ -0,0 +1,5 @@
+plugins:
+  - removeXMLProcInst: false
+  - cleanupIDs: false
+  - collapseGroups: false
+  - mergePaths: false
\ No newline at end of file
diff --git a/Gather.alias.php b/Gather.alias.php
new file mode 100644
index 0000000..8d38483
--- /dev/null
+++ b/Gather.alias.php
@@ -0,0 +1,15 @@
+<?php
+/**
+ * Aliases for Gather extension
+ *
+ * @file
+ * @ingroup Extensions
+ */
+// @codingStandardsIgnoreFile
+
+$specialPageAliases = array();
+
+/** English (English) */
+$specialPageAliases['en'] = array(
+       'Gather' => array( 'Gather' ),
+);
diff --git a/Gather.php b/Gather.php
new file mode 100644
index 0000000..19146fd
--- /dev/null
+++ b/Gather.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Extension Gather
+ *
+ * @file
+ * @ingroup Extensions
+ * @author Jon Robson
+ * @author Joaquin Hernandez
+ * @author Rob Moen
+ * @licence GNU General Public Licence 2.0 or later
+ */
+
+// Needs to be called within MediaWiki; not standalone
+if ( !defined( 'MEDIAWIKI' ) ) {
+       echo "This is a MediaWiki extension and cannot run standalone.\n";
+       die( -1 );
+}
+
+// Extension credits that will show up on Special:Version
+$wgExtensionCredits['other'][] = array(
+       'path' => __FILE__,
+       'name' => 'Gather',
+       'author' => array( 'Jon Robson', 'Joaquin Hernandez', 'Rob Moen' ),
+       'descriptionmsg' => 'gather-desc',
+       'url' => 'https://www.mediawiki.org/wiki/Gather',
+       'license-name' => 'GPL-2.0+',
+);
+
+$wgMessagesDirs['Gather'] = __DIR__ . '/i18n';
+$wgExtensionMessagesFiles['GatherAlias'] = __DIR__ . "/Gather.alias.php";
+
+function efGatherExtensionSetup() {
+       // FIXME: This doesn't do anything as if mobilefrontend is not present
+       // The reported error is "This requires Gather."
+       if ( !defined( 'MOBILEFRONTEND' ) ) {
+               echo "Gather extension requires MobileFrontend.\n";
+               die( -1 );
+       }
+}
+
+// autoload extension classes
+$autoloadClasses = array (
+       'Gather\Hooks' => 'Gather.hooks',
+
+       'Gather\Collection' => 'models/Collection',
+       'Gather\CollectionsList' => 'models/CollectionsList',
+
+       'Gather\CollectionStore' => 'stores/CollectionStore',
+       'Gather\WatchlistCollectionStore' => 'stores/WatchlistCollectionStore',
+
+       'Gather\View' => 'views/View',
+       'Gather\UserNotFoundView' => 'views/UserNotFoundView',
+       'Gather\CollectionView' => 'views/CollectionView',
+       'Gather\CollectionItemCardView' => 'views/CollectionItemCardView',
+       'Gather\CollectionsListView' => 'views/CollectionsListView',
+       'Gather\CollectionsListItemCardView' => 
'views/CollectionsListItemCardView',
+
+       'Gather\SpecialGather' => 'specials/SpecialGather',
+);
+
+foreach ( $autoloadClasses as $className => $classFilename ) {
+       $wgAutoloadClasses[$className] = __DIR__ . 
"/includes/$classFilename.php";
+}
+
+$wgExtensionFunctions[] = 'efGatherExtensionSetup';
+
+$wgSpecialPages['Gather'] = 'Gather\SpecialGather';
+
+// ResourceLoader modules
+require_once __DIR__ . "/includes/Resources.php";
+
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..9122bd5
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,9 @@
+# ruby=ruby-2.1.1
+# ruby-gemset=Gather
+
+source 'https://rubygems.org'
+
+gem 'chunky_png'
+gem 'jsduck'
+gem 'mediawiki_selenium', '~> 0.3.2'
+gem 'rubocop', require: false
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..a382242
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,111 @@
+GEM
+  remote: https://rubygems.org/
+  specs:
+    ast (2.0.0)
+    astrolabe (1.3.0)
+      parser (>= 2.2.0.pre.3, < 3.0)
+    builder (3.2.2)
+    childprocess (0.5.5)
+      ffi (~> 1.0, >= 1.0.11)
+    chunky_png (1.3.3)
+    cucumber (1.3.17)
+      builder (>= 2.1.2)
+      diff-lcs (>= 1.1.3)
+      gherkin (~> 2.12)
+      multi_json (>= 1.7.5, < 2.0)
+      multi_test (>= 0.1.1)
+    data_magic (0.20)
+      faker (>= 1.1.2)
+      yml_reader (>= 0.4)
+    diff-lcs (1.2.5)
+    dimensions (1.2.0)
+    domain_name (0.5.22)
+      unf (>= 0.0.5, < 1.0.0)
+    faker (1.4.3)
+      i18n (~> 0.5)
+    faraday (0.9.0)
+      multipart-post (>= 1.2, < 3)
+    faraday-cookie_jar (0.0.6)
+      faraday (>= 0.7.4)
+      http-cookie (~> 1.0.0)
+    ffi (1.9.6)
+    gherkin (2.12.2)
+      multi_json (~> 1.3)
+    headless (1.0.2)
+    http-cookie (1.0.2)
+      domain_name (~> 0.5)
+    i18n (0.6.11)
+    jsduck (5.3.4)
+      dimensions (~> 1.2.0)
+      json (~> 1.8.0)
+      parallel (~> 0.7.1)
+      rdiscount (~> 2.1.6)
+      rkelly-remix (~> 0.0.4)
+    json (1.8.1)
+    mediawiki_api (0.3.0)
+      faraday (~> 0.9, >= 0.9.0)
+      faraday-cookie_jar (~> 0.0, >= 0.0.6)
+    mediawiki_selenium (0.3.2)
+      cucumber (~> 1.3, >= 1.3.10)
+      headless (~> 1.0, >= 1.0.1)
+      json (~> 1.8, >= 1.8.1)
+      mediawiki_api (~> 0.2, >= 0.2.1)
+      page-object (~> 1.0)
+      rest-client (~> 1.6, >= 1.6.7)
+      rspec-expectations (~> 2.14, >= 2.14.4)
+      syntax (~> 1.2, >= 1.2.0)
+    mime-types (2.4.3)
+    multi_json (1.10.1)
+    multi_test (0.1.1)
+    multipart-post (2.0.0)
+    netrc (0.9.0)
+    page-object (1.0.2)
+      page_navigation (>= 0.9)
+      selenium-webdriver (>= 2.42.0)
+      watir-webdriver (>= 0.6.9)
+    page_navigation (0.9)
+      data_magic (>= 0.14)
+    parallel (0.7.1)
+    parser (2.2.0.pre.8)
+      ast (>= 1.1, < 3.0)
+      slop (~> 3.4, >= 3.4.5)
+    powerpack (0.0.9)
+    rainbow (2.0.0)
+    rdiscount (2.1.7.1)
+    rest-client (1.7.2)
+      mime-types (>= 1.16, < 3.0)
+      netrc (~> 0.7)
+    rkelly-remix (0.0.6)
+    rspec-expectations (2.99.2)
+      diff-lcs (>= 1.1.3, < 2.0)
+    rubocop (0.27.1)
+      astrolabe (~> 1.3)
+      parser (>= 2.2.0.pre.7, < 3.0)
+      powerpack (~> 0.0.6)
+      rainbow (>= 1.99.1, < 3.0)
+      ruby-progressbar (~> 1.4)
+    ruby-progressbar (1.7.0)
+    rubyzip (1.1.6)
+    selenium-webdriver (2.44.0)
+      childprocess (~> 0.5)
+      multi_json (~> 1.0)
+      rubyzip (~> 1.0)
+      websocket (~> 1.0)
+    slop (3.6.0)
+    syntax (1.2.0)
+    unf (0.1.4)
+      unf_ext
+    unf_ext (0.0.6)
+    watir-webdriver (0.6.11)
+      selenium-webdriver (>= 2.18.0)
+    websocket (1.2.1)
+    yml_reader (0.4)
+
+PLATFORMS
+  ruby
+
+DEPENDENCIES
+  chunky_png
+  jsduck
+  mediawiki_selenium (~> 0.3.2)
+  rubocop
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..dfb0395
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,160 @@
+/*!
+ * Grunt file
+ *
+ * @package Gather
+ */
+
+/*jshint node:true, strict:false*/
+module.exports = function ( grunt ) {
+       var MW_INSTALL_PATH = grunt.option( 'MW_INSTALL_PATH' ) || 
process.env.MW_INSTALL_PATH;
+
+       grunt.loadNpmTasks( 'grunt-contrib-jshint' );
+       grunt.loadNpmTasks( 'grunt-jscs' );
+       grunt.loadNpmTasks( 'grunt-qunit-istanbul' );
+       grunt.loadNpmTasks( 'grunt-contrib-watch' );
+       grunt.loadNpmTasks( 'grunt-notify' );
+       grunt.loadNpmTasks( 'grunt-svg2png' );
+       grunt.loadNpmTasks( 'grunt-jsduck' );
+       grunt.loadNpmTasks( 'grunt-contrib-clean' );
+       grunt.loadNpmTasks( 'grunt-mkdir' );
+
+       grunt.initConfig( {
+               URL: process.env.MEDIAWIKI_URL || 
'http://127.0.0.1:8080/w/index.php/',
+               QUNIT_DEBUG: ( process.env.QUNIT_DEBUG && '&debug=true' || '' ),
+               QUNIT_FILTER: ( process.env.QUNIT_FILTER && '&filter=' + 
process.env.QUNIT_FILTER ) || '',
+               QUNIT_MODULE: ( process.env.QUNIT_MODULE && '&module=' + 
process.env.QUNIT_MODULE ) || '',
+               files: {
+                       js: 'resources/**/*.js',
+                       jsTests: 'tests/qunit/**/*.js'
+               },
+               jshint: {
+                       options: {
+                               jshintrc: true
+                       },
+                       tests: '<%= files.jsTests %>',
+                       sources: [
+                               '<%= files.js %>',
+                               '!<%= files.jsExternals %>'
+                       ]
+               },
+               jscs: {
+                       main: [
+                               '<%= files.js %>'
+                       ],
+                       test: {
+                               options: {
+                                       config: '.jscsrctest.js',
+                               },
+                               files: {
+                                       src: '<%= files.jsTests %>'
+                               }
+                       }
+               },
+               qunit: {
+                       all: {
+                               options: {
+                                       timeout: 20000,
+                                       urls: [
+                                               '<%= URL 
%>Special:JavaScriptTest/qunit?useformat=mobile' +
+                                               '<%= QUNIT_DEBUG %><%= 
QUNIT_FILTER %><%= QUNIT_MODULE %>'
+                                       ]
+                               }
+                       },
+                       cov: {
+                               options: {
+                                       timeout: 20000,
+                                       urls: [
+                                               '<%= URL 
%>Special:JavaScriptTest/qunit?debug=true&useformat=mobile' +
+                                               '<%= QUNIT_FILTER %><%= 
QUNIT_MODULE %>'
+                                       ],
+                                       coverage: {
+                                               prefixUrl: 'w/', // Prefix url 
on the server
+                                               baseUrl: '../../', // Path to 
assets from the server (extensions/Mobile...)
+                                               src: [ '<%= files.js %>', '!<%= 
files.jsExternals %>' ],
+                                               instrumentedFiles: 
'tests/report/tmp',
+                                               htmlReport: 'tests/report'
+                                       }
+                               }
+                       }
+               },
+               watch: {
+                       lint: {
+                               files: [ '<%= files.js %>', '<%= files.jsTests 
%>' ],
+                               tasks: [ 'lint' ]
+                       },
+                       scripts: {
+                               files: [ '<%= files.js %>', '<%= files.jsTests 
%>' ],
+                               tasks: [ 'test' ]
+                       },
+                       configFiles: {
+                               files: [ 'Gruntfile.js' ],
+                               options: {
+                                       reload: true
+                               }
+                       }
+               },
+               mkdir: {
+                       jsdocs: {
+                               options: {
+                                       create: [ 'docs/js' ]
+                               }
+                       }
+               },
+               clean: {
+                       jsdocs: [ 'docs/js' ]
+               },
+               jsduck: {
+                       main: {
+                               src: [ '<%= files.js %>', '!<%= 
files.jsExternals %>' ],
+                               dest: 'docs/js',
+                               options: {
+                                       'builtin-classes': true,
+                                       'external': [
+                                               'Hogan.Template',
+                                               'HandleBars.Template',
+                                               'jQuery.Deferred',
+                                               'jQuery.Event',
+                                               'jQuery.Object',
+                                               'jqXHR',
+                                               'File',
+                                               'mw.user',
+                                               'OO.EventEmitter'
+                                       ],
+                                       'ignore-global': true,
+                                       'tags': './.docs/jsduckCustomTags.rb',
+                                       'warnings': [ '-nodoc(class,public)', 
'-dup_member', '-link_ambiguous' ]
+                               }
+                       }
+               }
+       } );
+
+       grunt.registerTask( 'checkInstallPath', 'Check if the install path is 
set', function () {
+               checkInstallPathNotFound( MW_INSTALL_PATH, grunt );
+       } );
+
+       grunt.registerTask( 'lint', [ 'jshint', 'jscs' ] );
+       grunt.registerTask( 'docs', [ 'checkInstallPath', 'clean:jsdocs', 
'mkdir:jsdocs', 'jsduck:main' ] );
+
+       // grunt test will be run by npm test which will be run by Jenkins
+       // Do not execute qunit here, or other tasks that require full mediawiki
+       // running.
+       grunt.registerTask( 'test', [ 'lint' ] );
+
+       grunt.registerTask( 'default', [ 'test' ] );
+};
+
+/**
+ * Checks if the path is set, and if not it prints an error message and bails
+ * out
+ */
+function checkInstallPathNotFound( MW_INSTALL_PATH, grunt ) {
+       if ( !MW_INSTALL_PATH ) {
+               grunt.log.error(
+                       'MW_INSTALL_PATH is not set. Please set it to your root 
mediawiki installation or pass the --MW_INSTALL_PATH to grunt.\n\n' +
+                       '\n    export 
MW_INSTALL_PATH=/Users/johndoe/dev/mediawiki' +
+                       '\n    MW_INSTALL_PATH=../../ grunt' +
+                       '\n    grunt --MW_INSTALL_PATH=../../'
+               );
+               process.exit( 1 );
+       }
+}
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..d881dfc
--- /dev/null
+++ b/README.txt
@@ -0,0 +1,2 @@
+This is a skunkworks project with the goal of iterating quickly.
+When we have something ready to put in front of users it will be 
productionised.
diff --git a/extension.json b/extension.json
new file mode 100644
index 0000000..c15a00d
--- /dev/null
+++ b/extension.json
@@ -0,0 +1,95 @@
+{
+       "name": "Gather",
+       "author": [
+               "Jon Robson",
+               "Joaquin Hernandez",
+               "Rob Moen"
+       ],
+       "url": "https://www.mediawiki.org/wiki/Gather";,
+       "descriptionmsg": "gather-desc",
+       "license-name": "GPL-2.0+",
+       "type": "other",
+       "ExtensionFunctions": [
+               "efGatherExtensionSetup"
+       ],
+       "SpecialPages": {
+               "Gather": "Gather\\SpecialGather"
+       },
+       "MessagesDirs": {
+               "Gather": [
+                       "i18n"
+               ]
+       },
+       "ExtensionMessagesFiles": {
+               "GatherAlias": "Gather.alias.php"
+       },
+       "AutoloadClasses": {
+               "Gather\\Hooks": "includes/Gather.hooks.php",
+               "Gather\\Collection": "includes/models/Collection.php",
+               "Gather\\CollectionsList": 
"includes/models/CollectionsList.php",
+               "Gather\\CollectionStore": 
"includes/stores/CollectionStore.php",
+               "Gather\\WatchlistCollectionStore": 
"includes/stores/WatchlistCollectionStore.php",
+               "Gather\\View": "includes/views/View.php",
+               "Gather\\UserNotFoundView": 
"includes/views/UserNotFoundView.php",
+               "Gather\\CollectionView": "includes/views/CollectionView.php",
+               "Gather\\CollectionItemCardView": 
"includes/views/CollectionItemCardView.php",
+               "Gather\\CollectionsListView": 
"includes/views/CollectionsListView.php",
+               "Gather\\CollectionsListItemCardView": 
"includes/views/CollectionsListItemCardView.php",
+               "Gather\\SpecialGather": "includes/specials/SpecialGather.php"
+       },
+       "ResourceModules": {
+               "ext.collections.icons": {
+                       "localBasePath": "includes",
+                       "remoteExtPath": "Gather",
+                       "targets": [
+                               "mobile",
+                               "desktop"
+                       ],
+                       "class": "ResourceLoaderImageModule",
+                       "prefix": "mw-ui",
+                       "images": {
+                               "icon": {
+                                       "collections-read-more:before": 
"images/icons/next.svg"
+                               }
+                       }
+               },
+               "ext.collections.styles": {
+                       "localBasePath": "includes",
+                       "remoteExtPath": "Gather",
+                       "targets": [
+                               "mobile",
+                               "desktop"
+                       ],
+                       "styles": [
+                               "resources/ext.collections.styles/icons.less",
+                               
"resources/ext.collections.styles/collections.less"
+                       ],
+                       "dependencies": [
+                               "mediawiki.ui.anchor",
+                               "skins.minerva.special.styles"
+                       ],
+                       "position": "top",
+                       "group": "other"
+               }
+       },
+       "config": {
+               "GatherResourceBoilerplate": {
+                       "localBasePath": "extensions/Gather/includes",
+                       "remoteExtPath": "Gather"
+               },
+               "GatherResourceFileModuleBoilerplate": {
+                       "localBasePath": "extensions/Gather/includes",
+                       "remoteExtPath": "Gather",
+                       "targets": [
+                               "mobile",
+                               "desktop"
+                       ]
+               },
+               "GatherMobileSpecialPageResourceBoilerplate": {
+                       "localBasePath": "extensions/Gather/includes",
+                       "remoteExtPath": "Gather",
+                       "targets": "mobile",
+                       "group": "other"
+               }
+       }
+}
diff --git a/i18n/en.json b/i18n/en.json
new file mode 100644
index 0000000..58d5a73
--- /dev/null
+++ b/i18n/en.json
@@ -0,0 +1,15 @@
+{
+       "@metadata": {
+               "authors": []
+       },
+       "gather-desc": "Component of Mobile Frontend allowing users to curate 
lists.",
+       "gather-anon-view-lists": "You need to be logged in to see your 
Collections.",
+       "gather-watchlist-title": "Watchlist",
+       "gather-watchlist-description": "A list of pages that I am interested 
in.",
+       "gather-lists-title": "Collections",
+       "gather-read-more": "Read more",
+       "gather-private": "Private",
+       "gather-article-count": "$1 {{PLURAL:$1|article|articles}}",
+       "gather-empty": "Nothing in this collection yet...",
+       "gather-empty-footer": "I don't know how you got here but this is a sad 
place."
+}
diff --git a/i18n/qqq.json b/i18n/qqq.json
new file mode 100644
index 0000000..8ae0ecf
--- /dev/null
+++ b/i18n/qqq.json
@@ -0,0 +1,15 @@
+{
+       "@metadata": {
+               "authors": []
+       },
+       "gather-desc": 
"{{desc|name=Gather|url=https://www.mediawiki.org/wiki/Extension:Gather}}";,
+       "gather-anon-view-lists": "Text shown when trying to see your own 
collections on [[Special:Gather]] but the user is not logged in.",
+       "gather-watchlist-title": "Title used for special casing the Watchlist 
collection on the [[Special:Gather]] page. ",
+       "gather-watchlist-description": "Default description for special casing 
the Watchlist collection on the [[Special:Gather]] page.",
+       "gather-lists-title": "Title for [[Special:Gather]] when displaying 
user curated lists.",
+       "gather-read-more": "Label for the read more link used on 
[[Special:Gather]].",
+       "gather-private": "Label for a collection when it is not publicly 
visible",
+       "gather-article-count": "Expression of the number of articles in a 
collection.\nParameter:\n * $1 - number of articles in the collection",
+       "gather-empty": "Message shown on an empty rendered collection on 
[[Special:Gather]].",
+       "gather-empty-footer": "Footnote shown on an empty rendered collection 
on [[Special:Gather]]."
+}
diff --git a/images/icons/next.svg b/images/icons/next.svg
new file mode 100644
index 0000000..5b2290e
--- /dev/null
+++ b/images/icons/next.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 17.5 26" 
enable-background="new 0 0 17.5 26">
+    <g>
+        <g>
+            <path fill="#347BFF" d="M4.6 24.3l-3.1-3 8.4-8.4-8.4-8.4 3.1-3L16 
12.9z"/>
+        </g>
+    </g>
+</svg>
diff --git a/includes/Gather.hooks.php b/includes/Gather.hooks.php
new file mode 100644
index 0000000..0fa36bb
--- /dev/null
+++ b/includes/Gather.hooks.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * Gather.hooks.php
+ */
+
+namespace Gather;
+
+/**
+ * Hook handlers for Gather extension
+ *
+ * Hook handler method names should be in the form of:
+ *     on<HookName>()
+ * For intance, the hook handler for the 'RequestContextCreateSkin' would be 
called:
+ *     onRequestContextCreateSkin()
+ */
+class Hooks {
+
+}
diff --git a/includes/Resources.php b/includes/Resources.php
new file mode 100644
index 0000000..3509268
--- /dev/null
+++ b/includes/Resources.php
@@ -0,0 +1,78 @@
+<?php
+/**
+ * Definition of Gather's ResourceLoader modules.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+if ( !defined( 'MEDIAWIKI' ) ) {
+       die( 'Not an entry point.' );
+}
+
+/**
+ * A boilerplate for RL modules that do not support templates
+ * Agnostic to whether desktop or mobile specific.
+ */
+$wgGatherResourceBoilerplate = array(
+       'localBasePath' => __DIR__,
+       'remoteExtPath' => 'Gather',
+);
+
+/**
+ * A mobile enabled ResourceLoaderFileModule template
+ */
+$wgGatherResourceFileModuleBoilerplate = $wgGatherResourceBoilerplate + array(
+       'targets' => array( 'mobile', 'desktop' ),
+);
+
+/**
+ * A boilerplate containing common properties for all RL modules served to 
mobile site special pages
+ * Restricted to mobile site.
+ */
+$wgGatherMobileSpecialPageResourceBoilerplate = $wgGatherResourceBoilerplate + 
array(
+       'targets' => 'mobile',
+       'group' => 'other',
+);
+
+$wgResourceModules = array_merge( $wgResourceModules, array(
+
+       'ext.collections.icons' => $wgGatherResourceFileModuleBoilerplate + 
array(
+               'class' => 'ResourceLoaderImageModule',
+               'prefix' => 'mw-ui',
+               'images' => array(
+                       // FIXME: ':before' suffix should be configurable in 
image module.
+                       'icon' => array(
+                               'collections-read-more:before' => 
'images/icons/next.svg',
+                       ),
+               ),
+       ),
+
+       'ext.collections.styles' => $wgGatherResourceFileModuleBoilerplate + 
array(
+               'styles' => array(
+                       'resources/ext.collections.styles/icons.less',
+                       'resources/ext.collections.styles/collections.less',
+               ),
+               'dependencies' => array(
+                       'mediawiki.ui.anchor',
+                       'skins.minerva.special.styles'
+               ),
+               'position' => 'top',
+               'group' => 'other',
+       ),
+
+) );
diff --git a/includes/models/Collection.php b/includes/models/Collection.php
new file mode 100644
index 0000000..8006468
--- /dev/null
+++ b/includes/models/Collection.php
@@ -0,0 +1,170 @@
+<?php
+
+/**
+ * Collection.php
+ */
+
+namespace Gather;
+
+/**
+ * A collection of pages, which are represented by the MobilePage class.
+ */
+class Collection implements IteratorAggregate {
+
+       /**
+        * The internal collection of pages.
+        *
+        * @var MobilePage[]
+        */
+       protected $pages = array();
+
+       /**
+        * Owner of collection
+        * @var User
+        */
+       protected $owner;
+
+       /**
+        * @var string
+        */
+       protected $title;
+
+       /**
+        * @var string
+        */
+       protected $description;
+
+       /**
+        * Whether collection is public or private
+        * Collection by default is true
+        *
+        * @var bool
+        */
+       protected $public;
+
+       /**
+        * @param User $user User that owns the collection
+        * @param string $title Title of the collection
+        * @param string $description Description of the collection
+        */
+       public function __construct( User $user, $title = '', $description = 
'', $public = true ) {
+               $this->user = $user;
+               $this->title = $title;
+               $this->description = $description;
+               $this->public = $public;
+       }
+
+       /**
+        * The internal id of a collection
+        *
+        * @var int id
+        */
+       protected $id;
+
+       /**
+        * Adds a page to the collection.
+        *
+        * @param MobilePage $page
+        */
+       public function add( MobilePage $page ) {
+               $this->pages[] = $page;
+       }
+
+       /**
+        * Gets the iterator for the internal array
+        *
+        * @return ArrayIterator
+        */
+       public function getIterator() {
+               return new ArrayIterator( $this->pages );
+       }
+
+       /**
+        * @return User
+        */
+       public function getOwner() {
+               return $this->owner;
+       }
+
+       /**
+        * @return string
+        */
+       public function getTitle() {
+               return $this->title;
+       }
+
+       /**
+        * @return string
+        */
+       public function getDescription() {
+               return $this->description;
+       }
+
+       /**
+        * Returns if the list is public
+        *
+        * @return boolean
+        */
+       public function isPublic() {
+               return $this->public;
+       }
+
+       /**
+        * Set if the list is public
+        *
+        * @param boolean $public
+        */
+       public function setPublic( $public ) {
+               $this->public = $public;
+       }
+
+       /**
+        * @return int id The internal id of a collection
+        */
+       public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * Returns pages count
+        *
+        * @return int count of pages in collection
+        */
+       public function getCount() {
+               return count( $this->pages );
+       }
+
+       /**
+        * Return local url for collection
+        * Example: /wiki/Special:Gather/user/id
+        *
+        * @return string localized url for collection
+        */
+       public function getUrl() {
+               return SpecialPage::getTitleFor( 'Gather' )
+                       ->getSubpage( $this->getOwner() )
+                       ->getSubpage( $this->getId() )
+                       ->getLocalURL();
+       }
+
+       /**
+        * @return array list of pages
+        */
+       public function getPages() {
+               return $this->pages;
+       }
+
+       /**
+        * Adds an array of titles to the collection
+        *
+        * @param CollectionStore $store
+        */
+       public function load( CollectionStore $store ) {
+               $this->id = $store->getId();
+               $titles = $store->getTitles();
+               foreach ( $titles as $title ) {
+                       $this->add( new MobilePage( $title ) );
+               }
+       }
+
+}
diff --git a/includes/models/CollectionsList.php 
b/includes/models/CollectionsList.php
new file mode 100644
index 0000000..4e20490
--- /dev/null
+++ b/includes/models/CollectionsList.php
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * CollectionsList.php
+ */
+
+namespace Gather;
+
+/**
+ * A list of collections, which are represented by the Collection class.
+ */
+class CollectionsList implements IteratorAggregate {
+       /**
+        * @var Gather\Collection[] Internal list of collections.
+        */
+       protected $lists = array();
+
+       /**
+        * @var bool if the list can show private collections or not
+        */
+       protected $includePrivate;
+
+       /**
+        * Creates a list of collection cards
+        *
+        * @param User $user collection list owner
+        * @param boolean $includePrivate if the list can show private 
collections or not
+        */
+       public function __construct( User $user, $includePrivate = false ) {
+               $this->includePrivate = $includePrivate;
+
+               // Get watchlist collection (private)
+               // Directly avoid adding if not owner
+               if ( $includePrivate ) {
+                       $watchlist = new Gather\Collection(
+                               $user,
+                               wfMessage( 'gather-watchlist-title' ),
+                               wfMessage( 'gather-watchlist-description' ),
+                               false
+                       );
+                       $watchlist->load( new WatchlistCollectionStore( $user ) 
);
+
+                       $this->add( $watchlist );
+               }
+
+               // FIXME: Add from UserCollectionStore
+       }
+
+       /**
+        * Adds a page to the collection.
+        * If the collection to add is private, and this collection list does 
not include
+        * private items, the collection won't be added
+        *
+        * @param Gather\Collection $collection
+        */
+       public function add( Gather\Collection $collection ) {
+               if ( $this->includePrivate ||
+                       ( !$this->includePrivate && $collection->isPublic() ) ) 
{
+                       $this->lists[] = $collection;
+               }
+       }
+
+       /**
+        * Gets the iterator for the internal array
+        *
+        * @return ArrayIterator
+        */
+       public function getIterator() {
+               return new ArrayIterator( $this->lists );
+       }
+}
diff --git a/includes/specials/SpecialGather.php 
b/includes/specials/SpecialGather.php
new file mode 100644
index 0000000..3af1499
--- /dev/null
+++ b/includes/specials/SpecialGather.php
@@ -0,0 +1,120 @@
+<?php
+/**
+ * SpecialGather.php
+ */
+
+namespace Gather;
+
+/**
+ * Render a collection of articles.
+ */
+class SpecialGather extends SpecialPage {
+
+       public function __construct() {
+               parent::__construct( 'Gather' );
+       }
+
+       /**
+        * Render the special page and redirect the user to the editor (if page 
exists)
+        *
+        * @param string $subpage The name of the page to edit
+        */
+       public function execute( $subpage ) {
+
+               if ( $subpage ) {
+                       $args = explode( '/', $subpage );
+                       // If there is a user argument, that's what we want to 
use
+                       if ( isset( $args[0] ) ) {
+                               // Show specified user's collections
+                               $user = User::newFromName( $args[0] );
+                       } else {
+                               // Otherwise use current user
+                               $user = $this->getUser();
+                       }
+               } else {
+                       // For listing own lists, you need to be logged in
+                       $this->requireLogin( 'gather-anon-view-lists' );
+                       $user = $this->getUser();
+               }
+
+               if ( !( $user && $user->getId() ) ) {
+                       // Invalid user
+                       $this->renderUserNotFoundError();
+               } else {
+                       if ( isset( $args ) && isset( $args[1] ) ) {
+                               $id = $args[1];
+                               $this->renderUserCollection( $user, $id );
+                       } else {
+                               $this->renderUserCollectionsList( $user );
+                       }
+               }
+       }
+
+       /**
+        * Render an error when the user was not found
+        */
+       public function renderUserNotFoundError() {
+               $this->render( new Gather\UserNotFoundView() );
+       }
+
+       /**
+        * Renders a user collection
+        *
+        * @param User $user collection owner
+        * @param int $id collection id
+        */
+       public function renderUserCollection( User $user, $id ) {
+               $collection = new Gather\Collection(
+                       $user,
+                       $this->msg( 'gather-watchlist-title' ),
+                       $this->msg( 'gather-watchlist-description' )
+               );
+               // Watchlist lives at id 0
+               if ( (int)$id === 0 ) {
+                       // Watchlist is private
+                       $collection->setPublic( false );
+                       if ( $this->isOwner( $user ) ) {
+                               $collection->load( new 
WatchlistCollectionStore( $user ) );
+                       }
+               }
+               // FIXME: For empty-collection and not-allowed-to-see-this we 
are doing the
+               // same thing right now.
+               $this->render( new Gather\CollectionView( $collection ) );
+       }
+
+       /**
+        * Renders a list of user collections
+        *
+        * @param User $user owner of collections
+        */
+       public function renderUserCollectionsList( User $user ) {
+               $collectionsList = new Gather\CollectionsList( $user, 
$this->isOwner( $user ) );
+               $this->render( new Gather\CollectionsListView( $collectionsList 
) );
+       }
+
+       /**
+        * Render the special page using CollectionView and given collection
+        *
+        * @param View $view
+        */
+       public function render( $view ) {
+               $out = $this->getOutput();
+               $this->setHeaders();
+               $out->setProperty( 'unstyledContent', true );
+               $out->addModules( array( 'ext.collections.styles' ) );
+               $out->setPageTitle( $view->getTitle() );
+               $view->render( $out );
+       }
+
+       /**
+        * Returns if the user viewing the page is the owner of the 
collection/list
+        * we are viewing
+        *
+        * @param User $user user owner of the current page
+        *
+        * @return boolean
+        */
+       private function isOwner( User $user ) {
+               return $this->getUser()->getName() == $user->getName();
+       }
+}
diff --git a/includes/stores/CollectionStore.php 
b/includes/stores/CollectionStore.php
new file mode 100644
index 0000000..0f3b0d3
--- /dev/null
+++ b/includes/stores/CollectionStore.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Gather;
+
+/**
+ * Abstraction for collection storage.
+ */
+interface CollectionStore {
+       /**
+        * Get titles of all pages in the current collection.
+        *
+        * @return array titles
+        */
+       public function getTitles();
+
+       /**
+        * Get current collection identifier
+        *
+        * @return int id
+        */
+       public function getId();
+}
diff --git a/includes/stores/WatchlistCollectionStore.php 
b/includes/stores/WatchlistCollectionStore.php
new file mode 100644
index 0000000..0936a0d
--- /dev/null
+++ b/includes/stores/WatchlistCollectionStore.php
@@ -0,0 +1,61 @@
+<?php
+
+/**
+ * Abstraction for watchlist storage.
+ * FIXME: This should live in core and power Special:EditWatchlist
+ */
+class WatchlistCollectionStore implements CollectionStore {
+       /**
+        * @var title[]
+        */
+       protected $titles = array();
+
+       /**
+        * @inheritdoc
+        */
+       public function getTitles() {
+               return $this->titles;
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function getId() {
+               // Watchlist has hardcoded id of 0
+               return 0;
+       }
+
+       /**
+        * Initialise WatchlistCollectionStore from database
+        *
+        * @param User $user to lookup watchlist members for
+        */
+       public function __construct( User $user ) {
+               $list = array();
+               $dbr = wfGetDB( DB_SLAVE );
+
+               $res = $dbr->select(
+                       'watchlist',
+                       array( 'wl_namespace', 'wl_title'),
+                       array(
+                               'wl_user' => $user->getId(),
+                               'wl_namespace' => 0,
+                       ),
+                       __METHOD__,
+                       array( 'LIMIT' => 50,)
+               );
+
+               $titles = array();
+               if ( $res->numRows() > 0 ) {
+                       foreach ( $res as $row ) {
+                               $title = Title::makeTitle( $row->wl_namespace, 
$row->wl_title );
+                               $titles[] = $title;
+                       }
+                       $res->free();
+               }
+               GenderCache::singleton()->doTitlesArray( $titles );
+
+               $this->titles = $titles;
+       }
+
+}
diff --git a/includes/views/CollectionItemCardView.php 
b/includes/views/CollectionItemCardView.php
new file mode 100644
index 0000000..a1c3100
--- /dev/null
+++ b/includes/views/CollectionItemCardView.php
@@ -0,0 +1,60 @@
+<?php
+/**
+ * CollectionItemCardView.php
+ */
+
+namespace Gather;
+
+/**
+ * View for an item card in a mobile collection.
+ */
+class CollectionItemCardView extends Gather\View {
+       protected $item;
+
+       /**
+        * Constructor
+        * @param MobilePage $item
+        */
+       public function __construct( MobilePage $item ) {
+               $this->item = $item;
+       }
+
+       /**
+        * Returns title of collection page
+        * @returns string collection page title
+        */
+       public function getTitle() {
+               return $this->item->getTitle()->getText();
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function getHtml() {
+               $page = $this->item;
+               $title = $page->getTitle();
+               $html = Html::openElement( 'div', array( 'class' => 
'collection-item' ) ) .
+                       Html::openElement( 'h2', array( 'class' => 
'collection-item-title' ) ) .
+                       Html::element( 'a', array( 'href' => 
$title->getLocalUrl() ),
+                               $this->getTitle()
+                       ).
+                       Html::closeElement( 'h2' ) .
+                       Html::openElement( 'div', array( 'class' => 
'collection-item-footer' ) ) .
+                       Html::openElement( 'a',
+                               array(
+                                       'href' => $title->getLocalUrl(),
+                                       'class' => MobileUI::anchorClass( 
'progressive' )
+                               )
+                       ) .
+                       wfMessage( 'gather-read-more' )->text() .
+                       Html::element(
+                               'span',
+                               array( 'class' => MobileUI::iconClass( 
'collections-read-more', 'element', 'collections-read-more-arrow' ) ),
+                               ''
+                       ) .
+                       Html::closeElement( 'a' ) .
+                       Html::closeElement( 'div' ) .
+                       Html::closeElement( 'div' );
+               return $html;
+       }
+}
diff --git a/includes/views/CollectionView.php 
b/includes/views/CollectionView.php
new file mode 100644
index 0000000..509cdcb
--- /dev/null
+++ b/includes/views/CollectionView.php
@@ -0,0 +1,122 @@
+<?php
+/**
+ * CollectionView.php
+ */
+
+namespace Gather;
+
+/**
+ * Render a mobile card.
+ */
+class CollectionView extends Gather\View {
+       /**
+        * @var Gather\Collection
+        */
+       protected $collection;
+
+       /**
+        * @param Gather\Collection $collection
+        */
+       public function __construct( Gather\Collection $collection ) {
+               $this->collection = $collection;
+       }
+
+       /**
+        * Returns the rendered html for the collection header
+        * @param Gather\Collection $collection
+        *
+        * @return string Html
+        */
+       private function getHeaderHtml( Gather\Collection $collection ) {
+               $collection = $this->collection;
+               $description = $collection->getDescription();
+               $owner = $collection->getOwner();
+
+               $html = Html::openElement( 'div', array( 'class' => 
'collection-header' ) ) .
+                       Html::element( 'div', array( 'class' => 
'collection-description' ), $description ) .
+                       $this->getOwnerHtml( $owner ) .
+                       Html::closeElement( 'div' );
+
+               return $html;
+       }
+
+       /**
+        * Returns the html for showing the owner on the collection header
+        *
+        * @param User $owner Owner of the collection
+        * @return string Html
+        */
+       private function getOwnerHtml( $owner ) {
+               $name = $owner->getName();
+               $attrs = array(
+                       'class' => 'collection-owner mw-ui-icon 
mw-ui-icon-before mw-ui-icon-user',
+                       'href' => SpecialPage::getTitleFor( 'UserProfile', 
$name )->getLocalUrl(),
+               );
+               return Html::element( 'span', $attrs, $name );
+       }
+
+       /**
+        * Returns the html for an empty collection
+        *
+        * @return string HTML
+        */
+       private function getEmptyCollectionMessage() {
+               // FIXME: i18n this messagesinclude 'CollectionView.php';
+               return Html::openElement( 'div', array( 'class' => 
'collection-empty' ) ) .
+                       Html::element( 'h3', array(), wfMessage( 'gather-empty' 
) ) .
+                       Html::element( 'div', array(),
+                               wfMessage( 'gather-empty-footer' ) ) .
+                       Html::closeElement( 'div' );
+       }
+
+       /**
+        * Return title of collection
+        *
+        * @return string collection title
+        */
+       public function getTitle() {
+               return $this->collection->getTitle();
+       }
+
+       /**
+        * Returns the html for the items of a collection
+        *
+        * @param Gather\Collection
+        *
+        * @return string HTML
+        */
+       public function getCollectionItems( Gather\Collection $collection ) {
+               $html = Html::openElement( 'div', array( 'class' => 
'collection-items' ) );
+               foreach ( $collection as $item ) {
+                       if ( $item->getTitle()->getNamespace() === NS_MAIN ) {
+                               $view = new CollectionItemCardView( $item );
+                               $html .= $view->getHtml();
+                       }
+               }
+               // FIXME: Pagination(??) Note the WatchlistCollectionStore
+               // limits the size of the collection to 50.
+               // Pagination may or may not be needed.
+               $html .= Html::closeElement( 'div' );
+               return $html;
+       }
+
+       /**
+        * @inheritdoc
+        */
+       protected function getHtml() {
+               $collection = $this->collection;
+
+               $html = Html::openElement( 'div', array( 'class' => 'collection 
content' ) ) .
+                       $this->getHeaderHtml( $collection );
+
+               if ( count( $collection->getPages() ) > 0 ) {
+                       $html .= $this->getCollectionItems( $collection );
+               } else {
+                       $html .= $this->getEmptyCollectionMessage();
+               }
+
+               $html .= Html::closeElement( 'div' );
+
+               return $html;
+       }
+}
diff --git a/includes/views/CollectionsListItemCardView.php 
b/includes/views/CollectionsListItemCardView.php
new file mode 100644
index 0000000..9649cfa
--- /dev/null
+++ b/includes/views/CollectionsListItemCardView.php
@@ -0,0 +1,55 @@
+<?php
+/**
+ * CollectionsListItemCardView.php
+ */
+
+namespace Gather;
+
+/**
+ * View for an item card in a mobile collection.
+ */
+class CollectionsListItemCardView extends Gather\View {
+
+       /**
+        * @param Gather\Collection $collection
+        */
+       public function __construct( Gather\Collection $collection ) {
+               $this->collection = $collection;
+       }
+
+       protected $collection;
+
+       /**
+        * Return title of collection
+        *
+        * @returns string collection title
+        */
+       public function getTitle() {
+               return $this->collection->getTitle();
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function getHtml() {
+               $articleCountMsg = wfMessage(
+                       'gather-article-count',
+                       $this->collection->getCount()
+               )->text();
+               // FIXME: should consider privacy in collection
+               $followingMsg = wfMessage( 'gather-private' )->text();
+               $collectionUrl = $this->collection->getUrl();
+
+               $html = Html::openElement( 'div', array( 'class' => 
'collection-card' ) ) .
+                       Html::openElement( 'div', array( 'class' => 
'collection-card-overlay' ) ) .
+                       Html::openElement( 'div', array( 'class' => 
'collection-card-title' ) ) .
+                       Html::element( 'a', array( 'href' => $collectionUrl ), 
$this->getTitle() ) .
+                       Html::closeElement( 'div' ) .
+                       Html::element( 'span', array( 'class' => 
'collection-card-following' ), $followingMsg ) .
+                       Html::element( 'span', array( 'class' => 
'collection-card-following' ), '•' ) .
+                       Html::element( 'span', array( 'class' => 
'collectoin-card-article-count' ), $articleCountMsg ) .
+                       Html::closeElement( 'div' ) .
+                       Html::closeElement( 'div' );
+               return $html;
+       }
+}
diff --git a/includes/views/CollectionsListView.php 
b/includes/views/CollectionsListView.php
new file mode 100644
index 0000000..d8f9c60
--- /dev/null
+++ b/includes/views/CollectionsListView.php
@@ -0,0 +1,56 @@
+<?php
+/**
+ * CollectionsListView.php
+ */
+
+namespace Gather;
+
+/**
+ * Renders a mobile collection card list
+ */
+class CollectionsListView extends Gather\View {
+       /**
+        * @param Gather\Collection $collection
+        */
+       public function __construct( Gather\CollectionList $collectionList ) {
+               $this->collectionList = $collectionList;
+       }
+
+       /**
+        * Returns the html for the items of a collection
+        *
+        * @param Gather\CollectionList
+        *
+        * @return string Html
+        */
+       public static function getListItemsHtml( Gather\CollectionList 
$collectionList ) {
+               $html = Html::openElement( 'div', array( 'class' => 
'collection-cards' ) );
+               foreach ( $collectionList as $item ) {
+                       $view = new Gather\CollectionsListItemCardView( $item );
+                       $html .= $view->getHtml();
+               }
+               // FIXME: Pagination
+               $html .= Html::closeElement( 'div' );
+               return $html;
+       }
+
+       /**
+        * Return title of collection
+        *
+        * @return string title for page showing curated lists
+        */
+       public function getTitle() {
+               return wfMessage( 'gather-lists-title' )->text();
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function getHtml() {
+               $html = Html::openElement( 'div', array( 'class' => 'collection 
content' ) );
+               // Get items
+               $html .= $this->getListItemsHtml( $this->collectionList );
+               $html .= Html::closeElement( 'div' );
+               return $html;
+       }
+}
diff --git a/includes/views/UserNotFoundView.php 
b/includes/views/UserNotFoundView.php
new file mode 100644
index 0000000..f99e085
--- /dev/null
+++ b/includes/views/UserNotFoundView.php
@@ -0,0 +1,33 @@
+
+<?php
+/**
+ * UserNotFoundView.php
+ */
+
+namespace Gather;
+
+/**
+ * Renders an error when the user wasn't found
+ */
+class UserNotFoundView extends Gather\View {
+
+       /**
+        * @inheritdoc
+        */
+       public function getTitle() {
+               return wfMessage( 'mobile-frontend-generic-404-title' )->text();
+       }
+
+       /**
+        * @inheritdoc
+        */
+       public function getHtml() {
+               // FIXME: Showing generic not found error right now. Show user 
not found instead
+               $html = Html::openElement( 'div', array( 'class' => 'collection 
user-not-found' ) );
+               $html .= Html::element( 'span', array( 'class' => 'mw-ui-anchor 
mw-ui-destructive' ),
+                       wfMessage( 'mobile-frontend-generic-404-desc' ) 
)->text();
+               $html .= Html::closeElement( 'div' );
+               return $html;
+       }
+
+}
diff --git a/includes/views/View.php b/includes/views/View.php
new file mode 100644
index 0000000..9f0bb85
--- /dev/null
+++ b/includes/views/View.php
@@ -0,0 +1,36 @@
+<?php
+/**
+ * View.php
+ */
+
+namespace Gather;
+
+/**
+ * Render a view.
+ */
+abstract class View {
+       /**
+        * Returns the html for the view
+        *
+        * @private
+        * @return string Html
+        */
+       abstract protected function getHtml();
+
+       /**
+        * Returns the title for the view
+        *
+        * @private
+        * @return string Html
+        */
+       abstract protected function getTitle();
+
+       /**
+        * Adds HTML of the view to the OutputPage.
+        *
+        * @param OutputPage $out
+        */
+       public function render( OutputPage $out ) {
+               $out->addHTML( $this->getHtml() );
+       }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..1e8bc0a
--- /dev/null
+++ b/package.json
@@ -0,0 +1,35 @@
+{
+       "name": "Gather-dependencies",
+       "description": "Node.js dependencies used in Gather",
+       "version": "0.0.1",
+       "scripts": {
+               "test": "grunt test"
+       },
+       "repository": {
+               "type": "git",
+               "url": 
"git://git.wikimedia.org/git/mediawiki/extensions/Gather.git"
+       },
+       "dependencies": {
+               "grunt": "0.4.5",
+               "grunt-phpdocumentor": "~0.4.1",
+               "jshint": ">=1.1.0",
+               "jscs": "git+https://github.com/jscs-dev/node-jscs.git#160c9dc";,
+               "jsdoc": "<=3.3.0",
+               "kss": ">=0.3.6",
+               "svgo": ">=0.4.4"
+       },
+       "devDependencies": {
+               "grunt-contrib-clean": "^0.6.0",
+               "grunt-mkdir": "^0.1.2",
+               "grunt-svg2png": "0.2.5",
+               "grunt-contrib-jshint": "0.10.0",
+               "grunt-jscs": 
"git+https://github.com/jdlrobson/grunt-jscs.git#mobilefrontend";,
+               "jscs-jsdoc": 
"git+https://github.com/jdlrobson/jscs-jsdoc.git#checkParamExistence";,
+               "grunt-jsduck": "^1.0.1",
+               "js-beautify": "^1.5.4",
+               "grunt-notify": "^0.3.1",
+               "grunt-contrib-watch": "0.6.1",
+               "grunt-lib-phantomjs-istanbul": "0.6.0",
+               "grunt-qunit-istanbul": 
"git+https://github.com/joakin/grunt-qunit-istanbul.git#prefix-url";
+       }
+}
diff --git a/resources/ext.collections.styles/collections.less 
b/resources/ext.collections.styles/collections.less
new file mode 100644
index 0000000..0eba394
--- /dev/null
+++ b/resources/ext.collections.styles/collections.less
@@ -0,0 +1,166 @@
+@import "minerva.variables";
+@import "minerva.mixins";
+
+.reset-special-page-heading-styles() {
+       // Reset .mw-mf-special #content_wrapper styles for headers...
+       // FIXME: Bad defaults.
+       text-align: left;
+       font-family: @fontFamilyHeading;
+       line-height: 1.3em;
+       margin: 0;
+}
+.reset-link-styles() {
+       a, a:visited, a:hover {
+               color: inherit;
+               text-decoration: none;
+       }
+}
+
+// FIXME: Bad special page styling defaults.
+#section_0 {
+       padding-bottom: 0;
+}
+
+.collection {
+
+       // Push the content up to be close to the title
+       // FIXME: Bad special page styling defaults.
+       &.content {
+               padding-top: 0;
+       }
+
+       /*
+        *  Collection page
+        */
+
+       .collection-header {
+               text-align: center;
+               padding: 0 1em;
+               margin-bottom: 2em;
+
+               h1 {
+                       font-weight: bold;
+               }
+
+               .collection-description {
+                       color: @grayMediumLight;
+               }
+
+               .collection-owner {
+                       &.mw-ui-icon-before:before {
+                               margin-right: 0.5em;
+                               width: 1em;
+                       }
+               }
+
+               .collection-action-button {
+                       padding: 0.5em 2em;
+               }
+       }
+
+       .collection-empty {
+               text-align: center;
+               * {
+                       font-family: @fontFamily;
+               }
+               h3 {
+                       padding-bottom: 0em;
+               }
+               h6 {
+                       font-size: 0.8em;
+               }
+       }
+
+       .collection-items {
+       }
+
+       .collection-item {
+               @collectionItemPadding: 1em;
+
+               border: 1px solid @grayLightest;
+               padding: 0 @collectionItemPadding;
+               margin-bottom: 2em;
+
+               .collection-item-title {
+                       font-size: 2em;
+                       padding-bottom: 0;
+                       .reset-special-page-heading-styles();
+                       // FIXME: Why is this inconsistent with other headings 
colors
+                       color: black;
+                       .reset-link-styles();
+               }
+
+               .collection-item-footer {
+                       // Make footer run to edges
+                       margin: 1em -@collectionItemPadding 0;
+                       padding: @collectionItemPadding;
+                       text-align: right;
+                       border-top: 1px solid @grayLightest;
+                       font-weight: bold;
+
+                       & * {
+                               // Align the items on the footer
+                               display: inline-block;
+                               vertical-align: bottom;
+                       }
+
+                       .collections-read-more-arrow {
+                               // Need a small icon size
+                               width: 1.5em;
+                               min-width: 1.5em;
+
+                               &:before {
+                                       // FIXME: mw-ui icons assume 
square/horizontal icons, this svg
+                                       // (next.svg) is vertical, so we have 
to switch the background-size.
+                                       // This shouldn't be necessary, either 
fix the icon(?) or mw-ui to
+                                       // support vertical icons.
+                                       background-size: auto 100%;
+                                       // Fix icon spacing
+                                       margin-left: 0.5em;
+                                       margin-right: 0;
+                               }
+                       }
+               }
+       }
+
+
+       /*
+        *  List of collections
+        */
+
+       @overlayHeight: 6em;
+
+       .collection-card {
+               height: @overlayHeight;
+               border: 1px solid @grayLightest;
+               position: relative;
+       }
+
+       .collection-card-overlay {
+               position: absolute;
+               height: @overlayHeight;
+               background: rgba(0, 0, 0, 0.8);
+               padding: 1em;
+               bottom: 0;
+               left: 0;
+               right: 0;
+               span {
+                       color: #FAF9F9;
+                       padding: 0 0.25em;
+               }
+       }
+
+       .collection-card-title {
+               font-size: 2em;
+               padding-bottom: 0;
+               color: #FAF9F9;
+               .reset-special-page-heading-styles();
+               .reset-link-styles();
+       }
+
+       // User not found page
+       &.user-not-found {
+               text-align: center;
+       }
+
+}
diff --git a/resources/ext.collections.styles/icons.less 
b/resources/ext.collections.styles/icons.less
new file mode 100644
index 0000000..e698ed0
--- /dev/null
+++ b/resources/ext.collections.styles/icons.less
@@ -0,0 +1,9 @@
+// FIXME: This file should be shared across repositories
+@import "minerva.variables";
+@import "minerva.mixins";
+@import "mediawiki.mixins";
+
+.mw-ui-icon-collections-read-more {
+       .m-background-image-svg-quick( 'images/icons/next' );
+}
+
diff --git a/resources/ext.collections.styles/images/icons/next.svg 
b/resources/ext.collections.styles/images/icons/next.svg
new file mode 100644
index 0000000..5b2290e
--- /dev/null
+++ b/resources/ext.collections.styles/images/icons/next.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 17.5 26" 
enable-background="new 0 0 17.5 26">
+    <g>
+        <g>
+            <path fill="#347BFF" d="M4.6 24.3l-3.1-3 8.4-8.4-8.4-8.4 3.1-3L16 
12.9z"/>
+        </g>
+    </g>
+</svg>
diff --git a/tests/.htaccess b/tests/.htaccess
new file mode 100644
index 0000000..986a40a
--- /dev/null
+++ b/tests/.htaccess
@@ -0,0 +1,4 @@
+Deny from all
+<FilesMatch "\.js$">
+       Allow from all
+</FilesMatch>
diff --git a/tests/browser/.gitignore b/tests/browser/.gitignore
new file mode 100644
index 0000000..5509614
--- /dev/null
+++ b/tests/browser/.gitignore
@@ -0,0 +1,5 @@
+# Puppet-managed dependencies for browser tests
+.bundle
+.gem
+.ruby-version
+screenshots

-- 
To view, visit https://gerrit.wikimedia.org/r/189157
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I82d787b4cfbd2c704db7680859c71e0e2d3f4c94
Gerrit-PatchSet: 7
Gerrit-Project: mediawiki/extensions/Gather
Gerrit-Branch: master
Gerrit-Owner: Robmoen <[email protected]>
Gerrit-Reviewer: Alex Monk <[email protected]>
Gerrit-Reviewer: Jdlrobson <[email protected]>
Gerrit-Reviewer: Jhernandez <[email protected]>
Gerrit-Reviewer: Legoktm <[email protected]>
Gerrit-Reviewer: MaxSem <[email protected]>
Gerrit-Reviewer: Robmoen <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to