├── .gitignore ├── .ruby-version ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Rakefile ├── app.rb ├── config.ru ├── config ├── database.yml └── environment.rb ├── db ├── development.sqlite ├── migrate │ ├── 20191230190514_create_readers.rb │ ├── 20200115021512_add_date_last_sent_to_readers.rb │ └── 20200115033259_add_status_to_readers.rb ├── readers.db └── schema.rb ├── letters ├── 01_best_tabs.html ├── 02_dreams.html ├── 03_family_slang.html ├── 04_time.html ├── 05_dating.html ├── 06_decisions.html ├── inlined │ ├── 01_best_tabs.html │ ├── 02_dreams.html │ ├── 03_family_slang.html │ ├── 04_time.html │ ├── 05_dating.html │ ├── 06_decisions.html │ └── may_i_recommend │ │ ├── 01_embark.html │ │ ├── 02_popcorn.html │ │ ├── 03_elliptical.html │ │ ├── 04_library.html │ │ ├── 05_poetry.html │ │ ├── 06_sparklets.html │ │ ├── 07_cartoons.html │ │ ├── 08_lamplight.html │ │ ├── 09_list.html │ │ ├── 10_advice.html │ │ ├── 11_badly.html │ │ ├── 12_birthday.html │ │ ├── 13_popsicles.html │ │ ├── 14_reality.html │ │ ├── 15_asleep.html │ │ ├── 16_rain.html │ │ ├── 17_trains.html │ │ ├── 18_spooky.html │ │ ├── 19_meals.html │ │ ├── 20_movies.html │ │ ├── 21_guessing.html │ │ ├── 22_endings.html │ │ └── 23_census.html ├── may_i_recommend │ ├── 01_embark_simple.html │ ├── 02_popcorn.html │ ├── 03_elliptical.html │ ├── 04_library.html │ ├── 05_poetry.html │ ├── 06_sparklets.html │ ├── 07_cartoons.html │ ├── 08_lamplight.html │ ├── 09_list.html │ ├── 10_advice.html │ ├── 11_badly.html │ ├── 12_birthday.html │ ├── 13_popsicles.html │ ├── 14_reality.html │ ├── 15_asleep.html │ ├── 16_rain.html │ ├── 17_trains.html │ ├── 18_spooky.html │ ├── 19_meals.html │ ├── 20_movies.html │ ├── 21_guessing.html │ ├── 22_endings.html │ └── 23_census.html └── scratch │ ├── 00_test.html │ ├── 01_embark.html │ ├── 13_popsicles.yaml │ └── template.html ├── models ├── archive.rb ├── letter.rb ├── mailer.rb └── reader.rb ├── public └── css │ ├── normalize.css │ ├── waves-gq.css │ └── waves.css └── views ├── about.erb ├── archive.erb ├── clock_viz.erb ├── confirm_unsubscribe.erb ├── layout.erb ├── layout_mir.erb ├── letter.erb ├── subscribe.erb ├── subscription_pending.erb ├── unsubscribe.erb ├── viz.erb └── welcome.erb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | .DS_Store 14 | 15 | # Used by dotenv library to load environment variables. 16 | .env 17 | 18 | # Ignore Byebug command history file. 19 | .byebug_history 20 | 21 | ## Specific to RubyMotion: 22 | .dat* 23 | .repl_history 24 | build/ 25 | *.bridgesupport 26 | build-iPhoneOS/ 27 | build-iPhoneSimulator/ 28 | 29 | ## Specific to RubyMotion (use of CocoaPods): 30 | # 31 | # We recommend against adding the Pods directory to your .gitignore. However 32 | # you should judge for yourself, the pros and cons are mentioned at: 33 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 34 | # 35 | # vendor/Pods/ 36 | 37 | ## Documentation cache and generated files: 38 | /.yardoc/ 39 | /_yardoc/ 40 | /doc/ 41 | /rdoc/ 42 | 43 | ## Environment normalization: 44 | /.bundle/ 45 | /vendor/bundle 46 | /lib/bundler/man/ 47 | 48 | # for a library or gem, you might want to ignore these files since the code is 49 | # intended to run in multiple environments; otherwise, check them in: 50 | # Gemfile.lock 51 | # .ruby-version 52 | # .ruby-gemset 53 | 54 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 55 | .rvmrc 56 | 57 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 58 | # .rubocop-https?--* 59 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.6.0 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby '2.6.0' 4 | 5 | gem 'activerecord' 6 | gem 'mailgun-ruby', '~>1.1.6' 7 | gem 'nokogiri' 8 | gem 'pg' 9 | gem 'pony' 10 | gem 'progress_bar' 11 | gem 'rake' 12 | gem 'require_all' 13 | gem 'sinatra' 14 | gem 'sinatra-activerecord' 15 | 16 | group :development do 17 | gem 'shotgun' 18 | gem 'pry' 19 | gem 'tux' 20 | gem 'dotenv' 21 | end 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activemodel (6.0.2.1) 5 | activesupport (= 6.0.2.1) 6 | activerecord (6.0.2.1) 7 | activemodel (= 6.0.2.1) 8 | activesupport (= 6.0.2.1) 9 | activesupport (6.0.2.1) 10 | concurrent-ruby (~> 1.0, >= 1.0.2) 11 | i18n (>= 0.7, < 2) 12 | minitest (~> 5.1) 13 | tzinfo (~> 1.1) 14 | zeitwerk (~> 2.2) 15 | bond (0.5.1) 16 | coderay (1.1.2) 17 | concurrent-ruby (1.1.5) 18 | domain_name (0.5.20190701) 19 | unf (>= 0.0.5, < 1.0.0) 20 | dotenv (2.7.5) 21 | highline (2.0.3) 22 | http-cookie (1.0.3) 23 | domain_name (~> 0.5) 24 | i18n (1.7.0) 25 | concurrent-ruby (~> 1.0) 26 | mail (2.7.1) 27 | mini_mime (>= 0.1.1) 28 | mailgun-ruby (1.1.11) 29 | rest-client (~> 2.0.2) 30 | method_source (0.9.2) 31 | mime-types (3.3.1) 32 | mime-types-data (~> 3.2015) 33 | mime-types-data (3.2020.0512) 34 | mini_mime (1.0.2) 35 | mini_portile2 (2.4.0) 36 | minitest (5.13.0) 37 | mustermann (1.1.0) 38 | ruby2_keywords (~> 0.0.1) 39 | netrc (0.11.0) 40 | nokogiri (1.10.10) 41 | mini_portile2 (~> 2.4.0) 42 | options (2.3.2) 43 | pg (1.2.0) 44 | pony (1.13.1) 45 | mail (>= 2.0) 46 | progress_bar (1.3.1) 47 | highline (>= 1.6, < 3) 48 | options (~> 2.3.0) 49 | pry (0.12.2) 50 | coderay (~> 1.1.0) 51 | method_source (~> 0.9.0) 52 | rack (2.2.3) 53 | rack-protection (2.0.7) 54 | rack 55 | rack-test (0.6.3) 56 | rack (>= 1.0) 57 | rake (13.0.1) 58 | require_all (3.0.0) 59 | rest-client (2.0.2) 60 | http-cookie (>= 1.0.2, < 2.0) 61 | mime-types (>= 1.16, < 4.0) 62 | netrc (~> 0.8) 63 | ripl (0.7.1) 64 | bond (~> 0.5.1) 65 | ripl-multi_line (0.3.1) 66 | ripl (>= 0.3.6) 67 | ripl-rack (0.2.1) 68 | rack (>= 1.0) 69 | rack-test (~> 0.6.2) 70 | ripl (>= 0.7.0) 71 | ruby2_keywords (0.0.1) 72 | shotgun (0.9.2) 73 | rack (>= 1.0) 74 | sinatra (2.0.7) 75 | mustermann (~> 1.0) 76 | rack (~> 2.0) 77 | rack-protection (= 2.0.7) 78 | tilt (~> 2.0) 79 | sinatra-activerecord (2.0.14) 80 | activerecord (>= 3.2) 81 | sinatra (>= 1.0) 82 | thread_safe (0.3.6) 83 | tilt (2.0.10) 84 | tux (0.3.0) 85 | ripl (>= 0.3.5) 86 | ripl-multi_line (>= 0.2.4) 87 | ripl-rack (>= 0.2.0) 88 | sinatra (>= 1.2.1) 89 | tzinfo (1.2.6) 90 | thread_safe (~> 0.1) 91 | unf (0.1.4) 92 | unf_ext 93 | unf_ext (0.0.7.7) 94 | zeitwerk (2.2.2) 95 | 96 | PLATFORMS 97 | ruby 98 | 99 | DEPENDENCIES 100 | activerecord 101 | dotenv 102 | mailgun-ruby (~> 1.1.6) 103 | nokogiri 104 | pg 105 | pony 106 | progress_bar 107 | pry 108 | rake 109 | require_all 110 | shotgun 111 | sinatra 112 | sinatra-activerecord 113 | tux 114 | 115 | RUBY VERSION 116 | ruby 2.6.0p0 117 | 118 | BUNDLED WITH 119 | 1.17.3 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Evangeline Garreau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Message in a Bottle 2 | 3 | A single-use, analytics-free email subscription platform built to distribute my newsletter. [Live site lives here](http://letters.evangelinegarreau.com/). 4 | 5 | ## Why? 6 | 7 | When I decided to start a newsletter, it was important to me to have a fully analytics-free experience with no indication of who's opening my emails or what links are being clicked on. I wanted to feel like I was tucking a carefully rolled letter into an old rinsed-out rootbeer bottle, sealing it with cork and wax, and then lobbing it out into the ocean and waving goodbye. Who knows whose shores it will wash upon? Maybe someday they'll write me back, or maybe I write the letter for no one but myself. 8 | 9 | ## How? 10 | 11 | This is a simple Sinatra web app with a Postgres database and an artisanal HTML/CSS front end. It uses [Mailgun](https://www.mailgun.com/) to send emails (see [models/mailer](models/mailer.rb) to check out the logic). Each send is triggered from the command line via a [rake task](Rakefile), which takes in an HTML file (the email) and a subject line and sends to the subscriber list. 12 | 13 | ## What's your newsletter? 14 | 15 | Currently it's called Good Question, a fortnightly essay based on survey responses to low-stakes, open-ended questions. [Subscribe here.](http://letters.evangelinegarreau.com/subscribe) 16 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require './config/environment' 2 | require 'sinatra/activerecord/rake' 3 | 4 | task :send, [:filename, :title, :send_type] do |t, args| 5 | if args[:send_type] == 'send to everyone' 6 | confirm_token = rand(36**6).to_s(36) 7 | STDOUT.puts "🌊🌊🌊" 8 | STDOUT.puts "Are you absolutely positive you're ready to send this letter? Enter '#{confirm_token}' to cast the bottle into the ocean:" 9 | STDOUT.puts "🌊🌊🌊" 10 | input = STDIN.gets.chomp 11 | raise "Bottle not cast. You entered #{input} instead of #{confirm_token}" unless input == confirm_token 12 | end 13 | 14 | letter = Letter.new(args[:title], args[:filename]) 15 | Mailer.send(letter, args[:send_type]) 16 | STDOUT.puts "🍾 Bottle cast! Your letter is bobbing away on the tide. 🌊" 17 | end 18 | -------------------------------------------------------------------------------- /app.rb: -------------------------------------------------------------------------------- 1 | require_relative 'config/environment' 2 | 3 | class MessageInABottle < Sinatra::Base 4 | 5 | get '/' do 6 | redirect to("http://letters.evangelinegarreau.com/subscribe"), 301 7 | end 8 | 9 | get '/about' do 10 | erb :about 11 | end 12 | 13 | get '/subscribe' do 14 | erb :subscribe 15 | end 16 | 17 | get '/unsubscribe' do 18 | erb :unsubscribe 19 | end 20 | 21 | get '/mayirecommend' do 22 | redirect to '/archive' 23 | end 24 | 25 | get '/archive' do 26 | @archive = ARCHIVE 27 | erb :archive, layout: :layout_mir 28 | end 29 | 30 | get '/archive/:letter' do 31 | index = params[:letter].to_i 32 | letter = ARCHIVE.letters[index] 33 | erb :letter, { layout: :layout_mir, locals: {letter: letter} } 34 | end 35 | 36 | get '/viz' do 37 | erb :viz 38 | end 39 | 40 | post '/create' do 41 | email = params['email'] 42 | reader = Reader.find_or_create_by(email: email) 43 | Mailer.request_subscribe(reader) 44 | redirect "/subscription-pending?email=#{reader.email}" 45 | end 46 | 47 | get '/subscription-pending' do 48 | erb :subscription_pending, locals: {email: params[:email]} 49 | end 50 | 51 | get '/confirm-subscribe/:id' do 52 | reader = Reader.find(params[:id]) 53 | reader.update(status: 'confirmed') 54 | redirect "/welcome?email=#{reader.email}" 55 | end 56 | 57 | get '/welcome' do 58 | erb :welcome, locals: {email: params[:email]} 59 | end 60 | 61 | post '/delete' do 62 | email = params['email'] 63 | Reader.destroy_by(email: email) 64 | if Reader.find_by(email: email) 65 | success = false 66 | else 67 | success = true 68 | end 69 | redirect "/confirm-unsubscribe?success=#{success}" 70 | end 71 | 72 | get '/confirm-unsubscribe' do 73 | erb :confirm_unsubscribe, locals: {success: params[:success]} 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require './config/environment' 2 | 3 | run MessageInABottle 4 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: postgresql 3 | database: readers_dev 4 | pool: 5 5 | timeout: 5000 6 | production: 7 | adapter: postgresql 8 | database: <%= ENV['DATABASE_URL'] %> 9 | pool: 5 10 | timeout: 5000 11 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | ENV['SINATRA_ENV'] ||= 'development' 2 | ENV['SINATRA_ACTIVESUPPORT_WARNING'] = 'false' 3 | 4 | if ENV['SINATRA_ENV'] == 'development' 5 | require 'dotenv' 6 | Dotenv.load 7 | end 8 | 9 | require 'bundler/setup' 10 | Bundler.require(:default, ENV['SINATRA_ENV']) 11 | 12 | require './app' 13 | require_all 'models' 14 | -------------------------------------------------------------------------------- /db/development.sqlite: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egarreau/messageinabottle/8a22ad074d7867dd245a01faa2b0ee2e448f344b/db/development.sqlite -------------------------------------------------------------------------------- /db/migrate/20191230190514_create_readers.rb: -------------------------------------------------------------------------------- 1 | class CreateReaders < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table :readers do |t| 4 | t.string :email 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200115021512_add_date_last_sent_to_readers.rb: -------------------------------------------------------------------------------- 1 | class AddDateLastSentToReaders < ActiveRecord::Migration[6.0] 2 | def change 3 | change_table :readers do |t| 4 | t.column :date_last_sent, :date 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20200115033259_add_status_to_readers.rb: -------------------------------------------------------------------------------- 1 | class AddStatusToReaders < ActiveRecord::Migration[6.0] 2 | def change 3 | change_table :readers do |t| 4 | t.column :status, :string, default: "pending" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/readers.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/egarreau/messageinabottle/8a22ad074d7867dd245a01faa2b0ee2e448f344b/db/readers.db -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `rails 6 | # db:schema:load`. When creating a new database, `rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2020_01_15_033259) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "readers", force: :cascade do |t| 19 | t.string "email" 20 | t.date "date_last_sent" 21 | t.string "status", default: "pending" 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /letters/02_dreams.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Good Question ✧ Dreams for 2021 7 | 173 | 174 | 175 | 176 | 177 | 264 | 265 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /letters/inlined/06_decisions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Good Question ✧ 7 | 83 | 84 | 85 | 86 | 87 | 143 | 144 | 145 | 146 | 147 | -------------------------------------------------------------------------------- /letters/inlined/may_i_recommend/09_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Lists 7 | 85 | 86 | 87 | 88 | 89 | 169 | 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /letters/inlined/may_i_recommend/20_movies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Movie Theaters 7 | 85 | 86 | 87 | 88 | 89 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /letters/may_i_recommend/01_embark_simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Blindly Embarking on A Public Project with No Certainty of Achievement 7 | 182 | 183 | 184 | 185 | 186 | 271 | 272 | 273 | 274 | 275 | -------------------------------------------------------------------------------- /letters/may_i_recommend/02_popcorn.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Popcorn and Pomegranate Seeds 7 | 172 | 173 | 174 | 175 | 176 | 264 | 265 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /letters/may_i_recommend/09_list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Lists 7 | 173 | 174 | 175 | 176 | 177 | 253 | 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /letters/may_i_recommend/20_movies.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Movie Theaters 7 | 173 | 174 | 175 | 176 | 177 | 260 | 261 | 262 | 263 | 264 | -------------------------------------------------------------------------------- /letters/may_i_recommend/23_census.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Readership census results 7 | 173 | 174 | 175 | 176 | 177 | 264 | 265 | 266 | 267 | 268 | -------------------------------------------------------------------------------- /letters/scratch/00_test.html: -------------------------------------------------------------------------------- 1 | 2 |

Hello! Thank you for subscribing!

3 |

This is a quick soundcheck to make sure the mic is on. Can everybody hear me? If this successfully hits your inbox, please respond with a hi or an emoji or, if you're feeling generous, something you recommend and why.

4 |

If all goes according to plan, the first May I Recommend will go out January 11th.

5 |

Thank you all for being here!

6 |

~E

7 | 8 | -------------------------------------------------------------------------------- /letters/scratch/01_embark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | May I Recommend ☞ Blindly Embarking on A Public Project with No Certainty of Achievement 7 | 197 | 198 | 199 | 200 | 201 | 202 | 304 | 305 | 306 | 307 | 308 | 309 | -------------------------------------------------------------------------------- /letters/scratch/13_popsicles.yaml: -------------------------------------------------------------------------------- 1 | { 2 | "img_link": "https://twitter.com/probirdrights/status/610565282256437251", 3 | "img": "https://pics.me.me/birdsrightsactivist-probirdrights-i-been-working-on-my-summer-bod-it-27395742.png", 4 | "img_alt": '@probirdsrights on twitter: "I been working on my summer bod: it the same as my regular body, but this time more popsackles in it."', 5 | "essay": '

In the disorienting tumult of the last 4 months(/years), I have become mildly compulsive about stocking food. My actual eating habits haven’t changed significantly, and I’m not hoarding food on an unreasonable scale, but I find myself getting much more anxious and flustered when I start to run low than I ever did before. And for some reason, this anxiety is focused most prominently on sweets.

6 |

I have always had a sweet tooth, and for years I purposefully banished all dessert items from my house in a misguided attempt to deprive myself joy in favor of some neo-puritan ideal of “health.” I thought I couldn’t let myself have what I wanted because I was incapable of restraint, when in fact I had deprived myself for so long that I hadn’t learned the feeling of enoughness; I had not yet learned to be satisfiable (as Adrienne Marie Brown puts it in Pleasure Activism). Slowly, over many years, my relationship to sweets (and to all food, and my body, and everything) began to evolve, soften, settle. I learned(/am still learning) how to delight in treats without guilt or shame, which opens my heart up to moderation.

7 |

When lockdown started, my little dessert habit suddenly became very important to me. I’ve spent a lot of time working on paying gentle attention to the needs of the animal body my soul inhabits, and as confusion and panic became a constant, she had some loud demands. It started with an intense craving for buckeyes, a candy I think I’ve had twice in my life. Something about it felt nostalgic and homey, and offered the kind of deep comfort I was seeking. I made a pan of buckeye brownies and slowly worked my way through it over the course of several weeks.

8 |

As my supply dwindled, I started to feel unusually nervous. It was a primal kind of anxiety; even though the more nutritious shelves in my fridge were full, some deep food-scarcity alarms were ringing and difficult to ignore. It seemed silly to give into, but whatever was happening in my heart and brain felt much older than my consciously held opinions on food, nutrition, and grocery shopping. I decided not to fight whatever reptilian defenses were trying to protect me, and wound up with a minor stockpile of ice cream and chocolate-covered pretzels.

9 |

Now, in the warm, sweet days of June, summer has officially arrived, cooling my appetite for chocolate and igniting a new love in my heart: popsicles. One of my favorite features of the Chicago lakefront is the paletas man with his bell-adorned cart, walking up and down the beach. I will always, always give up a couple dollars for a coconut paleta melting fast in its plastic sleeve. Even though I don’t foresee many beach trips this summer, popsicles are still my ideal sweet summer snack: they are cold, they are delicious, and they are absurdly easy to make. If you can use a blender or heat a pan, you can make a gourmet-level popsicle. I love how easy it is to be imaginative and inventive with popsicles — they’re so simple and fast to put together that it feels super low-stakes to take a risk with a new flavor combination. ALSO, they are a great way to use up all the fruit I bought and then failed to eat before it got mushy!

10 |

Some delightful flavors I’ve made:

11 | 16 |

When I load up my six little molds and stick them in the freezer, it feels like a promise to my future self that I will have moments of delight in my day. No matter what else is happening, I will have a cold, sweet treat to rely on. I’m not sure why having a freezer full of popsicles soothes me in a way that a fridge full of dinners doesn’t, but I’m glad I can offer myself a simple comfort that has such an outsized impact on my life.

', 17 | "pairing_list": '
  • Paletas by Fany Gerson. I bought this book on a whim one dreary February day when I needed a reminder that summer exists. The recipes, photography, and stories behind the food are so evocative and inspiring, and everything I’ve made from it has been delicious
  • 18 |
  • Popsicle interlude on Jamila Woods’ excellent album HEAVN, which is my forever summer jam
  • 19 |
  • Since I mentioned tacos I have to share with you this walnut-cauliflower “chorizo” that is just absurdly good. I’m usually not one for meat substitutes (I’d rather just eat vegetables that taste like vegetables) but this is so flavorful and has such a nice texture, I can’t wait to make it again
  • ', 20 | "totw_category": "Movie", 21 | "totw": "Disclosure", 22 | "totw_desc": "Disclosure is a documentary about the depiction of trans and gender-nonconforming folks in the media from the dawn of moving pictures through today. It’s brilliantly told — heartbreaking, but also fascinating and uplifting and nuanced and even sometimes funny. I really enjoyed watching it and I’m going to be thinking about it for a long time. A while ago I posted a quote on instagram from Angela Davis in which she says, “I don’t think we would be where we are today, encouraging ever larger numbers of people to think within an abolitionist frame, had not the trans community taught us that it is possible to effectively challenge that which is considered the very foundation of our sense of normalcy.” A friend messaged me to add that he thinks of this as “the trans ask”: that for non-trans folks, “recognition of trans experience asks (maybe demands!) that you see your OWN identity & sexuality as constructed & contingent.” I think this is at the heart of what makes transness so terrifying to so many, but it is the thing I am most grateful to the trans community for. Trans, non-binary, and gender-nonconforming folks have expanded my imagination of possibilities for myself and the world by an order of magnitude. By their very existence, trans folks demonstrate every day that, as Ursula K. Le Guin says, “Any human power can be resisted and changed by human beings.” Disclosure reminded me concretely that this is nothing new, and that world-expanding history stretches back through the ages.", 23 | "reader_rec": 'Tweefontein Herb Farm Lavender Balm', 24 | "reader_rec_desc": '“It smells heavenly, feels AMAZING, and now I use it on my daughter after her bath too. I use it on everything. Especially right now, it’s so awesome because it’s all-natural and antimicrobial and helps boost her tiny immune system. It’s from Tweefontein Herb Farm, which is a small business, and I live for supporting small businesses. It’s amazing, it’s magic, it’s one of my favorite things in life.” -Anya Navidi-Kasmai', 25 | "reader_rec_comment": "Who doesn't love a soothing balm?? Particularly in the summer, I know my skin needs extra love.", 26 | "writing_location": "on a steamy summer day" 27 | } 28 | -------------------------------------------------------------------------------- /letters/scratch/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Good Question ✧ 7 | 171 | 172 | 173 | 174 | 175 | 239 | 240 | 241 | 242 | 243 | -------------------------------------------------------------------------------- /models/archive.rb: -------------------------------------------------------------------------------- 1 | require 'nokogiri' 2 | class Archive 3 | attr_reader :letters 4 | 5 | def initialize 6 | @letters = build_library 7 | end 8 | 9 | def build_library 10 | letters = [] 11 | Dir.each_child('letters/may_i_recommend') do |letter| 12 | letters << ArchiveLetter.new("letters/may_i_recommend/#{letter}") 13 | end 14 | letters.sort_by { |l| l.index } 15 | end 16 | end 17 | 18 | class ArchiveLetter 19 | attr_reader :index, :title, :html 20 | def initialize(filename) 21 | @index = filename[24..25].to_i - 1 22 | @html, @title = parse(filename) 23 | end 24 | 25 | def parse(filename) 26 | page = ::Nokogiri::HTML.parse(File.read(filename)) 27 | table = page.css('table')[1].to_s 28 | title = page.css('title').text[18..] 29 | 30 | return table, title 31 | end 32 | end 33 | 34 | ARCHIVE = Archive.new 35 | -------------------------------------------------------------------------------- /models/letter.rb: -------------------------------------------------------------------------------- 1 | class Letter 2 | 3 | def initialize(title, path) 4 | @subject = "#{title}" 5 | @html = File.read(path) 6 | # @yaml = YAML.load_file(path) 7 | end 8 | 9 | def build(recipient) 10 | { 11 | :from => ["'Evangeline Garreau' #{ENV['gmail_user']}"], 12 | :to => recipient, 13 | :subject => "Good Question ✧ #{@subject}", 14 | :html => @html 15 | } 16 | end 17 | 18 | # currently unused due to troubleshooting issues 19 | # def build_with_template(recipient) 20 | # { 21 | # :from => ["'Evangeline Garreau' #{ENV['gmail_user']}"], 22 | # :to => recipient, 23 | # :subject => @subject, 24 | # :template => 'newsletter', 25 | # ":h:X-Mailgun-Variables" => @yaml 26 | # } 27 | # end 28 | 29 | # currently unused due to the fact that it causes all replies to thread in one email 30 | # def batch 31 | # message = Mailgun::BatchMessage.new(@bottle, ENV['mg_domain']) 32 | # message.from(ENV['gmail_user'], {'first' => 'Evangeline', 'last' => 'Garreau'}) 33 | # message.subject(@subject) 34 | # message.body_html(@html) 35 | # end 36 | end 37 | -------------------------------------------------------------------------------- /models/mailer.rb: -------------------------------------------------------------------------------- 1 | class Mailer 2 | BOTTLE = Mailgun::Client.new(ENV['mg_key']) 3 | 4 | def self.send(letter, scope) 5 | if scope == 'send to everyone' 6 | progress_bar = ProgressBar.new(Reader.all.length, :bar, :percentage) 7 | Reader.all.each do |reader| 8 | if reader.date_last_sent != Date.today && reader.status == 'confirmed' 9 | letter_hash = letter.build(reader.email) 10 | BOTTLE.send_message(ENV['mg_domain'], letter_hash) 11 | reader.update(date_last_sent: Date.today) 12 | progress_bar.increment! 13 | end 14 | end 15 | else 16 | letter_hash = letter.build(scope) 17 | BOTTLE.send_message(ENV['mg_domain'], letter_hash) 18 | end 19 | end 20 | 21 | def self.request_subscribe(reader) 22 | BOTTLE.send_message(ENV['mg_domain'], { :from => ["'Evangeline Garreau' #{ENV['gmail_user']}"], 23 | :to => reader.email, 24 | :subject => '🌊 Confirm your subscription to Good Question', 25 | :html => "

    Thank you for subscribing to Good Question! Click here to confirm your subscription.

    " }) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /models/reader.rb: -------------------------------------------------------------------------------- 1 | class Reader < ActiveRecord::Base 2 | validates :email, presence: true 3 | end 4 | -------------------------------------------------------------------------------- /public/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v3.0.2 | MIT License | git.io/normalize */ 2 | 3 | /** 4 | * 1. Set default font family to sans-serif. 5 | * 2. Prevent iOS text size adjust after orientation change, without disabling 6 | * user zoom. 7 | */ 8 | 9 | html { 10 | font-family: sans-serif; /* 1 */ 11 | -ms-text-size-adjust: 100%; /* 2 */ 12 | -webkit-text-size-adjust: 100%; /* 2 */ 13 | } 14 | 15 | /** 16 | * Remove default margin. 17 | */ 18 | 19 | body { 20 | margin: 0; 21 | } 22 | 23 | /* HTML5 display definitions 24 | ========================================================================== */ 25 | 26 | /** 27 | * Correct `block` display not defined for any HTML5 element in IE 8/9. 28 | * Correct `block` display not defined for `details` or `summary` in IE 10/11 29 | * and Firefox. 30 | * Correct `block` display not defined for `main` in IE 11. 31 | */ 32 | 33 | article, 34 | aside, 35 | details, 36 | figcaption, 37 | figure, 38 | footer, 39 | header, 40 | hgroup, 41 | main, 42 | menu, 43 | nav, 44 | section, 45 | summary { 46 | display: block; 47 | } 48 | 49 | /** 50 | * 1. Correct `inline-block` display not defined in IE 8/9. 51 | * 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera. 52 | */ 53 | 54 | audio, 55 | canvas, 56 | progress, 57 | video { 58 | display: inline-block; /* 1 */ 59 | vertical-align: baseline; /* 2 */ 60 | } 61 | 62 | /** 63 | * Prevent modern browsers from displaying `audio` without controls. 64 | * Remove excess height in iOS 5 devices. 65 | */ 66 | 67 | audio:not([controls]) { 68 | display: none; 69 | height: 0; 70 | } 71 | 72 | /** 73 | * Address `[hidden]` styling not present in IE 8/9/10. 74 | * Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22. 75 | */ 76 | 77 | [hidden], 78 | template { 79 | display: none; 80 | } 81 | 82 | /* Links 83 | ========================================================================== */ 84 | 85 | /** 86 | * Remove the gray background color from active links in IE 10. 87 | */ 88 | 89 | a { 90 | background-color: transparent; 91 | } 92 | 93 | /** 94 | * Improve readability when focused and also mouse hovered in all browsers. 95 | */ 96 | 97 | a:active, 98 | a:hover { 99 | outline: 0; 100 | } 101 | 102 | /* Text-level semantics 103 | ========================================================================== */ 104 | 105 | /** 106 | * Address styling not present in IE 8/9/10/11, Safari, and Chrome. 107 | */ 108 | 109 | abbr[title] { 110 | border-bottom: 1px dotted; 111 | } 112 | 113 | /** 114 | * Address style set to `bolder` in Firefox 4+, Safari, and Chrome. 115 | */ 116 | 117 | b, 118 | strong { 119 | font-weight: bold; 120 | } 121 | 122 | /** 123 | * Address styling not present in Safari and Chrome. 124 | */ 125 | 126 | dfn { 127 | font-style: italic; 128 | } 129 | 130 | /** 131 | * Address variable `h1` font-size and margin within `section` and `article` 132 | * contexts in Firefox 4+, Safari, and Chrome. 133 | */ 134 | 135 | h1 { 136 | font-size: 2em; 137 | margin: 0.67em 0; 138 | } 139 | 140 | /** 141 | * Address styling not present in IE 8/9. 142 | */ 143 | 144 | mark { 145 | background: #ff0; 146 | color: #000; 147 | } 148 | 149 | /** 150 | * Address inconsistent and variable font size in all browsers. 151 | */ 152 | 153 | small { 154 | font-size: 80%; 155 | } 156 | 157 | /** 158 | * Prevent `sub` and `sup` affecting `line-height` in all browsers. 159 | */ 160 | 161 | sub, 162 | sup { 163 | font-size: 75%; 164 | line-height: 0; 165 | position: relative; 166 | vertical-align: baseline; 167 | } 168 | 169 | sup { 170 | top: -0.5em; 171 | } 172 | 173 | sub { 174 | bottom: -0.25em; 175 | } 176 | 177 | /* Embedded content 178 | ========================================================================== */ 179 | 180 | /** 181 | * Remove border when inside `a` element in IE 8/9/10. 182 | */ 183 | 184 | img { 185 | border: 0; 186 | } 187 | 188 | /** 189 | * Correct overflow not hidden in IE 9/10/11. 190 | */ 191 | 192 | svg:not(:root) { 193 | overflow: hidden; 194 | } 195 | 196 | /* Grouping content 197 | ========================================================================== */ 198 | 199 | /** 200 | * Address margin not present in IE 8/9 and Safari. 201 | */ 202 | 203 | figure { 204 | margin: 1em 40px; 205 | } 206 | 207 | /** 208 | * Address differences between Firefox and other browsers. 209 | */ 210 | 211 | hr { 212 | -moz-box-sizing: content-box; 213 | box-sizing: content-box; 214 | height: 0; 215 | } 216 | 217 | /** 218 | * Contain overflow in all browsers. 219 | */ 220 | 221 | pre { 222 | overflow: auto; 223 | } 224 | 225 | /** 226 | * Address odd `em`-unit font size rendering in all browsers. 227 | */ 228 | 229 | code, 230 | kbd, 231 | pre, 232 | samp { 233 | font-family: monospace, monospace; 234 | font-size: 1em; 235 | } 236 | 237 | /* Forms 238 | ========================================================================== */ 239 | 240 | /** 241 | * Known limitation: by default, Chrome and Safari on OS X allow very limited 242 | * styling of `select`, unless a `border` property is set. 243 | */ 244 | 245 | /** 246 | * 1. Correct color not being inherited. 247 | * Known issue: affects color of disabled elements. 248 | * 2. Correct font properties not being inherited. 249 | * 3. Address margins set differently in Firefox 4+, Safari, and Chrome. 250 | */ 251 | 252 | button, 253 | input, 254 | optgroup, 255 | select, 256 | textarea { 257 | color: inherit; /* 1 */ 258 | font: inherit; /* 2 */ 259 | margin: 0; /* 3 */ 260 | } 261 | 262 | /** 263 | * Address `overflow` set to `hidden` in IE 8/9/10/11. 264 | */ 265 | 266 | button { 267 | overflow: visible; 268 | } 269 | 270 | /** 271 | * Address inconsistent `text-transform` inheritance for `button` and `select`. 272 | * All other form control elements do not inherit `text-transform` values. 273 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera. 274 | * Correct `select` style inheritance in Firefox. 275 | */ 276 | 277 | button, 278 | select { 279 | text-transform: none; 280 | } 281 | 282 | /** 283 | * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` 284 | * and `video` controls. 285 | * 2. Correct inability to style clickable `input` types in iOS. 286 | * 3. Improve usability and consistency of cursor style between image-type 287 | * `input` and others. 288 | */ 289 | 290 | button, 291 | html input[type="button"], /* 1 */ 292 | input[type="reset"], 293 | input[type="submit"] { 294 | -webkit-appearance: button; /* 2 */ 295 | cursor: pointer; /* 3 */ 296 | } 297 | 298 | /** 299 | * Re-set default cursor for disabled elements. 300 | */ 301 | 302 | button[disabled], 303 | html input[disabled] { 304 | cursor: default; 305 | } 306 | 307 | /** 308 | * Remove inner padding and border in Firefox 4+. 309 | */ 310 | 311 | button::-moz-focus-inner, 312 | input::-moz-focus-inner { 313 | border: 0; 314 | padding: 0; 315 | } 316 | 317 | /** 318 | * Address Firefox 4+ setting `line-height` on `input` using `!important` in 319 | * the UA stylesheet. 320 | */ 321 | 322 | input { 323 | line-height: normal; 324 | } 325 | 326 | /** 327 | * It's recommended that you don't attempt to style these elements. 328 | * Firefox's implementation doesn't respect box-sizing, padding, or width. 329 | * 330 | * 1. Address box sizing set to `content-box` in IE 8/9/10. 331 | * 2. Remove excess padding in IE 8/9/10. 332 | */ 333 | 334 | input[type="checkbox"], 335 | input[type="radio"] { 336 | box-sizing: border-box; /* 1 */ 337 | padding: 0; /* 2 */ 338 | } 339 | 340 | /** 341 | * Fix the cursor style for Chrome's increment/decrement buttons. For certain 342 | * `font-size` values of the `input`, it causes the cursor style of the 343 | * decrement button to change from `default` to `text`. 344 | */ 345 | 346 | input[type="number"]::-webkit-inner-spin-button, 347 | input[type="number"]::-webkit-outer-spin-button { 348 | height: auto; 349 | } 350 | 351 | /** 352 | * 1. Address `appearance` set to `searchfield` in Safari and Chrome. 353 | * 2. Address `box-sizing` set to `border-box` in Safari and Chrome 354 | * (include `-moz` to future-proof). 355 | */ 356 | 357 | input[type="search"] { 358 | -webkit-appearance: textfield; /* 1 */ 359 | -moz-box-sizing: content-box; 360 | -webkit-box-sizing: content-box; /* 2 */ 361 | box-sizing: content-box; 362 | } 363 | 364 | /** 365 | * Remove inner padding and search cancel button in Safari and Chrome on OS X. 366 | * Safari (but not Chrome) clips the cancel button when the search input has 367 | * padding (and `textfield` appearance). 368 | */ 369 | 370 | input[type="search"]::-webkit-search-cancel-button, 371 | input[type="search"]::-webkit-search-decoration { 372 | -webkit-appearance: none; 373 | } 374 | 375 | /** 376 | * Define consistent border, margin, and padding. 377 | */ 378 | 379 | fieldset { 380 | border: 1px solid #c0c0c0; 381 | margin: 0 2px; 382 | padding: 0.35em 0.625em 0.75em; 383 | } 384 | 385 | /** 386 | * 1. Correct `color` not being inherited in IE 8/9/10/11. 387 | * 2. Remove padding so people aren't caught out if they zero out fieldsets. 388 | */ 389 | 390 | legend { 391 | border: 0; /* 1 */ 392 | padding: 0; /* 2 */ 393 | } 394 | 395 | /** 396 | * Remove default vertical scrollbar in IE 8/9/10/11. 397 | */ 398 | 399 | textarea { 400 | overflow: auto; 401 | } 402 | 403 | /** 404 | * Don't inherit the `font-weight` (applied by a rule above). 405 | * NOTE: the default cannot safely be changed in Chrome and Safari on OS X. 406 | */ 407 | 408 | optgroup { 409 | font-weight: bold; 410 | } 411 | 412 | /* Tables 413 | ========================================================================== */ 414 | 415 | /** 416 | * Remove most spacing between table cells. 417 | */ 418 | 419 | table { 420 | border-collapse: collapse; 421 | border-spacing: 0; 422 | } 423 | 424 | td, 425 | th { 426 | padding: 0; 427 | } 428 | -------------------------------------------------------------------------------- /public/css/waves-gq.css: -------------------------------------------------------------------------------- 1 | /* 2 | 🌊 Waves, a tiny, fussy stylesheet 3 | * by Evangeline Garreau 4 | * January 1, 2020 5 | * Structure shamelessly stolen from Skeleton (www.getskeleton.com) 6 | * 7 | * refreshed August 4, 2020 by Annabeth Carroll to create the Lake Michigan remix 💙 8 | * 9 | * revamped January 10, 2021 by Evangeline Garreau to create the Good Question remix 10 | */ 11 | 12 | /* Table of contents 13 | –––––––––––––––––––––––––––––––––––––––––––––––––– 14 | * Variables 15 | * Base Styles 16 | * Text 17 | * Header 18 | * Nav 19 | * Form 20 | * Misc 21 | * Media Queries 22 | * - Note: styles are mobile-first, with desktop overrides found here 23 | * Archive-specific Styles 24 | */ 25 | 26 | /* Variables 27 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 28 | :root { 29 | --royal-purp: #51217a; 30 | --steely-dan: #595959; 31 | --base-text: #2b2b2b; 32 | } 33 | 34 | /* Base Styles 35 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 36 | html { 37 | font-size: 12px; 38 | font-family: "Rosario", sans-serif; 39 | } 40 | 41 | body { 42 | font-size: 1.5rem; 43 | line-height: 3rem; 44 | text-align: center; 45 | color: var(--base-text); 46 | } 47 | 48 | .container { 49 | margin: 0 auto; 50 | padding: 0 2rem 2rem 2rem; 51 | } 52 | 53 | .paragraph { 54 | line-height: 2rem; 55 | margin: 5rem 0 5rem 0; 56 | text-align: left; 57 | } 58 | 59 | /* Text 60 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 61 | h1, 62 | h2, 63 | h3 { 64 | font-family: "Ibarra Real Nova", serif; 65 | } 66 | 67 | p, ul { 68 | color: var(--steely-dan); 69 | } 70 | 71 | a { 72 | color: var(--royal-purp); 73 | text-decoration: none; 74 | font-weight: 900; 75 | } 76 | 77 | a:hover { 78 | text-decoration: underline; 79 | } 80 | 81 | /* Header 82 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 83 | .header { 84 | margin-bottom: 2rem; 85 | font-size: 2rem; 86 | line-height: 4rem; 87 | } 88 | 89 | .title { 90 | margin: 0; 91 | } 92 | 93 | .supertitle { 94 | margin-bottom: 0; 95 | } 96 | 97 | .subtitle { 98 | margin-top: 0.5rem; 99 | } 100 | 101 | .supertitle, 102 | .subtitle { 103 | font-style: italic; 104 | } 105 | 106 | /* Nav 107 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 108 | nav { 109 | padding: 2rem; 110 | margin-bottom: 2rem; 111 | border-bottom: 0.6rem double var(--royal-purp); 112 | display: flex; 113 | justify-content: center; 114 | align-items: center; 115 | } 116 | 117 | .nav-item { 118 | padding: 0 1rem; 119 | text-decoration: none; 120 | color: var(--steely-dan); 121 | } 122 | 123 | /* Form 124 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 125 | form { 126 | margin: 0 0 2rem 0; 127 | } 128 | 129 | label { 130 | border: 0; 131 | clip: rect(0 0 0 0); 132 | height: 1px; 133 | margin: -1px; 134 | overflow: hidden; 135 | padding: 0; 136 | position: absolute; 137 | width: 1px; 138 | } 139 | 140 | input[type="email"] { 141 | -webkit-appearance: none; 142 | -moz-appearance: none; 143 | appearance: none; /* Removes awkward default styles on some inputs for iOS */ 144 | margin: 1rem 0; 145 | border: 1rem solid var(--royal-purp); 146 | } 147 | 148 | input[type="submit"] { 149 | border: none; 150 | background: var(--royal-purp); 151 | color: #fff; 152 | } 153 | 154 | input[type="email"], 155 | input[type="submit"] { 156 | padding: 1rem; 157 | display: block; 158 | width: 100%; 159 | padding: 2rem; 160 | border-radius: 0.3rem; 161 | text-align: center; 162 | } 163 | 164 | /* Misc 165 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 166 | .break { 167 | display: block; 168 | text-align: center; 169 | } 170 | 171 | a button { 172 | border: none; 173 | background-color: var(--royal-purp); 174 | border-radius: 0.3rem; 175 | padding: .5rem 1.5rem; 176 | margin-right: 1rem; 177 | color: #fff; 178 | font-weight: normal; 179 | } 180 | 181 | /* Media Queries 182 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 183 | /* Desktop */ 184 | @media (min-width: 768px) { 185 | .container { 186 | max-width: 700px; 187 | padding: 0 5rem 5rem 5rem; 188 | } 189 | nav { 190 | margin-bottom: 4rem; 191 | } 192 | .nav-item { 193 | padding: 0 3rem; 194 | } 195 | .header { 196 | margin-bottom: 3rem; 197 | } 198 | form { 199 | margin: 3rem; 200 | } 201 | } 202 | 203 | /* Archive-specific Styles 204 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 205 | img { 206 | max-width: 60%; 207 | margin-left: auto; 208 | margin-right: auto; 209 | display: block; 210 | } 211 | 212 | table { 213 | width: 100%; 214 | font-size: 1.5rem; 215 | line-height: 2rem; 216 | } 217 | table h2 { 218 | margin-top: 2rem; 219 | margin-bottom: 0; 220 | } 221 | 222 | .dingbat { 223 | color: #FF6D70; 224 | font-size: 2rem; 225 | text-align: center; 226 | margin: 1rem; 227 | } 228 | 229 | ul.archive { 230 | list-style: none; 231 | padding: 0; 232 | margin: 0; 233 | } 234 | 235 | .archive li { 236 | padding: .5rem; 237 | } 238 | -------------------------------------------------------------------------------- /public/css/waves.css: -------------------------------------------------------------------------------- 1 | /* 2 | 🌊 Waves, a tiny, fussy stylesheet 3 | * by Evangeline Garreau 4 | * January 1, 2020 5 | * Structure shamelessly stolen from Skeleton (www.getskeleton.com) 6 | * 7 | * refreshed August 4, 2020 by Annabeth Carroll to create the Lake Michigan remix 💙 8 | */ 9 | 10 | /* Table of contents 11 | –––––––––––––––––––––––––––––––––––––––––––––––––– 12 | * Variables 13 | * Base Styles 14 | * Text 15 | * Header 16 | * Nav 17 | * Form 18 | * Misc 19 | * Media Queries 20 | * - Note: styles are mobile-first, with desktop overrides found here 21 | * Archive-specific Styles 22 | */ 23 | 24 | /* Variables 25 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 26 | :root { 27 | --fancy-forest: #008094; 28 | --steely-dan: #595959; 29 | --base-text: #2b2b2b; 30 | } 31 | 32 | /* Base Styles 33 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 34 | html { 35 | font-size: 12px; 36 | font-family: "Rosario", sans-serif; 37 | } 38 | 39 | body { 40 | font-size: 1.5rem; 41 | line-height: 3rem; 42 | text-align: center; 43 | color: var(--base-text); 44 | } 45 | 46 | .container { 47 | margin: 0 auto; 48 | padding: 0 2rem 2rem 2rem; 49 | } 50 | 51 | .paragraph { 52 | line-height: 2rem; 53 | margin: 5rem 0 5rem 0; 54 | text-align: left; 55 | } 56 | 57 | /* Text 58 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 59 | h1, 60 | h2, 61 | h3 { 62 | font-family: "Ibarra Real Nova", serif; 63 | } 64 | 65 | p, ul { 66 | color: var(--steely-dan); 67 | } 68 | 69 | a { 70 | color: var(--fancy-forest); 71 | text-decoration: none; 72 | font-weight: 900; 73 | } 74 | 75 | a:hover { 76 | text-decoration: underline; 77 | } 78 | 79 | /* Header 80 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 81 | .header { 82 | margin-bottom: 2rem; 83 | font-size: 2rem; 84 | line-height: 4rem; 85 | } 86 | 87 | .title { 88 | margin: 0; 89 | } 90 | 91 | .supertitle { 92 | margin-bottom: 0; 93 | } 94 | 95 | .subtitle { 96 | margin-top: 0.5rem; 97 | } 98 | 99 | .supertitle, 100 | .subtitle { 101 | font-style: italic; 102 | } 103 | 104 | /* Nav 105 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 106 | nav { 107 | padding: 2rem; 108 | margin-bottom: 2rem; 109 | border-bottom: 0.6rem double var(--fancy-forest); 110 | display: flex; 111 | justify-content: center; 112 | align-items: center; 113 | } 114 | 115 | .nav-item { 116 | padding: 0 1rem; 117 | text-decoration: none; 118 | color: var(--steely-dan); 119 | } 120 | 121 | /* Form 122 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 123 | form { 124 | margin: 0 0 2rem 0; 125 | } 126 | 127 | label { 128 | border: 0; 129 | clip: rect(0 0 0 0); 130 | height: 1px; 131 | margin: -1px; 132 | overflow: hidden; 133 | padding: 0; 134 | position: absolute; 135 | width: 1px; 136 | } 137 | 138 | input[type="email"] { 139 | -webkit-appearance: none; 140 | -moz-appearance: none; 141 | appearance: none; /* Removes awkward default styles on some inputs for iOS */ 142 | margin: 1rem 0; 143 | border: 1rem solid var(--fancy-forest); 144 | } 145 | 146 | input[type="submit"] { 147 | border: none; 148 | background: var(--fancy-forest); 149 | color: #fff; 150 | } 151 | 152 | input[type="email"], 153 | input[type="submit"] { 154 | padding: 1rem; 155 | display: block; 156 | width: 100%; 157 | padding: 2rem; 158 | border-radius: 0.3rem; 159 | text-align: center; 160 | } 161 | 162 | /* Misc 163 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 164 | .break { 165 | display: block; 166 | text-align: center; 167 | } 168 | 169 | a button { 170 | border: none; 171 | background-color: var(--fancy-forest); 172 | border-radius: 0.3rem; 173 | padding: .5rem 1.5rem; 174 | margin-right: 1rem; 175 | color: #fff; 176 | font-weight: normal; 177 | } 178 | 179 | /* Media Queries 180 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 181 | /* Desktop */ 182 | @media (min-width: 768px) { 183 | .container { 184 | max-width: 700px; 185 | padding: 0 5rem 5rem 5rem; 186 | } 187 | nav { 188 | margin-bottom: 4rem; 189 | } 190 | .nav-item { 191 | padding: 0 3rem; 192 | } 193 | .header { 194 | margin-bottom: 3rem; 195 | } 196 | form { 197 | margin: 3rem; 198 | } 199 | } 200 | 201 | /* Archive-specific Styles 202 | –––––––––––––––––––––––––––––––––––––––––––––––––– */ 203 | img { 204 | max-width: 60%; 205 | margin-left: auto; 206 | margin-right: auto; 207 | display: block; 208 | } 209 | 210 | table { 211 | width: 100%; 212 | font-size: 1.5rem; 213 | line-height: 2rem; 214 | } 215 | table h2 { 216 | margin-top: 2rem; 217 | margin-bottom: 0; 218 | } 219 | 220 | .dingbat { 221 | color: #FF6D70; 222 | font-size: 2rem; 223 | text-align: center; 224 | margin: 1rem; 225 | } 226 | 227 | ul.archive { 228 | list-style: none; 229 | padding: 0; 230 | margin: 0; 231 | } 232 | 233 | .archive li { 234 | padding: .5rem; 235 | } 236 | -------------------------------------------------------------------------------- /views/about.erb: -------------------------------------------------------------------------------- 1 |

    What’s all this?

    2 |
    3 |

    Good Question

    4 |

    Good Question is a fortnightly newsletter co-written with its readership, based on survey responses to low-stakes, open-ended questions. Each letter typically includes an essay, a mildly amusing data visualization, and various links to things I think are good.

    5 | 6 |
    7 |
    8 |

    Message In A Bottle

    9 |

    Message In A Bottle is the email subscription platform I built to distribute May I Recommend. It’s important to me to have a fully analytics-free experience, with no indication of who’s opening it, what links are clicked on, or any other traditional “engagement metrics”. I want to feel like I’m tucking a carefully rolled letter into an old rinsed-out rootbeer bottle, sealing it with cork and wax, and then lobbing it out into the ocean and waving goodbye. Who knows whose shores it will wash upon? Maybe someday they’ll write me back, or maybe I write the letter for no one but myself.

    10 |

    Source code here.

    11 |
    12 |
    13 |

    Colophon

    14 |

    This site is set in Rosario for paragraph text and Ibarra Real Nova for titles. It consists of a Sinatra app with a Postgres database hosted on Heroku. Emails are sent using Mailgun.

    15 |

    When I include images, they are typically sourced from the National Gallery of Art, the Smithsonian Institution, the Art Institute of Chicago, the Five Colleges and Historic Deerfield Museum Consortium, or The Cleveland Museum of Art.

    16 |
    17 | -------------------------------------------------------------------------------- /views/archive.erb: -------------------------------------------------------------------------------- 1 |

    ☞ May I Recommend ☜

    2 |

    May I Recommend is a newsletter about things I think are good, which ran from January 11th to December 27th, 2020. It takes inspiration from Ross Gay's Book of Delights, Robin Sloan’s Year of the Meteor, the podcast Wonderful, and my own irrepressible urge to tell people about the stuff I like. This is the archive.

    3 | 10 | -------------------------------------------------------------------------------- /views/clock_viz.erb: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 |

    What time is it?

    20 | 21 | 22 | 23 | 12 24 | 3 25 | 6 26 | 9 27 | AM 28 | PM 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | "A very productive time!" 37 | "Time for dinner on a bleakly familiar February day" 38 | "February, winter, feeling very much like the end of a year" 39 | "The beginning of Week 3 (of 16) of Spring Semester." 40 | 41 | 42 |
    43 |

    Data taken from this form, sent out February 2nd, 2021

    44 | 156 | -------------------------------------------------------------------------------- /views/confirm_unsubscribe.erb: -------------------------------------------------------------------------------- 1 | <% if success == "true" %> 2 |

    Unsubscribe Successful

    3 |
    4 |

    There is nothing in this finite life more valuable than time. I’m honored that you spent some of yours with me. Fairwell friend!

    5 | 🌊 6 |
    7 | <% else %> 8 |

    Hmm, something went weird

    9 |
    10 |

    I’m not sure what happened there but it looks like you haven’t been properly unsubscribed (sorry!). Please try again or let me know what happened at mayirec@gmail.com and I’ll do my best to sort you out.

    11 |
    12 | <% end %> 13 | -------------------------------------------------------------------------------- /views/layout.erb: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Good Question 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 31 |
    32 | <%= yield %> 33 |
    34 | 35 | 36 | -------------------------------------------------------------------------------- /views/layout_mir.erb: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | May I Recommend 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 29 |
    30 | <%= yield %> 31 |
    32 | 33 | 34 | -------------------------------------------------------------------------------- /views/letter.erb: -------------------------------------------------------------------------------- 1 | <%= letter.html %> 2 | -------------------------------------------------------------------------------- /views/subscribe.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    subscribe to

    3 |

    Good Question

    4 |

    from Evangeline Garreau

    5 |
    6 |
    7 | 8 | 9 | 10 |
    11 | Wait, what am I subscribing to? 12 | 13 | -------------------------------------------------------------------------------- /views/subscription_pending.erb: -------------------------------------------------------------------------------- 1 | <% if email != "" %> 2 |

    Thanks for subscribing!

    3 |
    4 |

    The email I have for you is <%= email %>. Please check your inbox for a confirmation email and click through to make sure you're properly subscribed. If you also want to add mayirec@gmail.com to your contacts that would be a blessing.

    5 |

    If you ever change your mind about letting me into your inbox, please unsubscribe without hesitation.

    6 | 🌊 7 |
    8 | <% else %> 9 |

    Hmm, something went weird

    10 |
    11 |

    I’m not sure what happened there but it looks like you haven’t been subscribed. Please try again or let me know what happened at mayirec@gmail.com and I’ll do my best to sort you out.

    12 |
    13 | <% end %> 14 | -------------------------------------------------------------------------------- /views/unsubscribe.erb: -------------------------------------------------------------------------------- 1 |
    2 |

    Unsubscribe

    3 |

    parting is such sweet sorrow!

    4 |
    5 |
    6 | 7 | 8 | 9 |
    10 | 11 | -------------------------------------------------------------------------------- /views/viz.erb: -------------------------------------------------------------------------------- 1 | 2 | 17 | 18 | 19 |

    What time is it?

    20 | 21 | 22 | 23 | 12 24 | 3 25 | 6 26 | 9 27 | AM 28 | PM 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | "A very productive time!" 37 | "Time for dinner on a bleakly familiar February day" 38 | "February, winter, feeling very much like the end of a year" 39 | "The beginning of Week 3 (of 16) of Spring Semester." 40 | 41 | 42 |
    43 |

    Data taken from this form, sent out February 2nd, 2021

    44 | 156 | -------------------------------------------------------------------------------- /views/welcome.erb: -------------------------------------------------------------------------------- 1 |

    Subscription Successful!

    2 |
    3 |

    Welcome to the archipelago, <%= email %>. My next missive should wash up on your shores within the next two weeks. If it does not, please reach out to mayirec at gmail dot com and I’ll do my best to sort you out.

    4 |

    If you ever change your mind about letting me into your inbox, please unsubscribe without hesitation.

    5 | 🌊 6 |
    7 | --------------------------------------------------------------------------------