pinnable 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +100 -0
  5. data/Rakefile +8 -0
  6. data/app/assets/javascripts/pinnable.js +308 -0
  7. data/app/assets/stylesheets/pinnable/application.css +83 -0
  8. data/app/controllers/pinnable/application_controller.rb +20 -0
  9. data/app/controllers/pinnable/comments_controller.rb +16 -0
  10. data/app/controllers/pinnable/markers_controller.rb +12 -0
  11. data/app/controllers/pinnable/pins_controller.rb +34 -0
  12. data/app/helpers/pinnable/application_helper.rb +4 -0
  13. data/app/helpers/pinnable/widget_helper.rb +11 -0
  14. data/app/jobs/pinnable/application_job.rb +4 -0
  15. data/app/mailers/pinnable/application_mailer.rb +6 -0
  16. data/app/models/pinnable/application_record.rb +5 -0
  17. data/app/models/pinnable/comment/encryption.rb +8 -0
  18. data/app/models/pinnable/comment/relationships.rb +8 -0
  19. data/app/models/pinnable/comment/validations.rb +7 -0
  20. data/app/models/pinnable/comment.rb +9 -0
  21. data/app/models/pinnable/pin/encryption.rb +9 -0
  22. data/app/models/pinnable/pin/relationships.rb +10 -0
  23. data/app/models/pinnable/pin/scopes.rb +8 -0
  24. data/app/models/pinnable/pin/transitions.rb +7 -0
  25. data/app/models/pinnable/pin/validations.rb +17 -0
  26. data/app/models/pinnable/pin.rb +12 -0
  27. data/app/serializers/pinnable/marker_serializer.rb +27 -0
  28. data/app/services/pinnable/add_comment.rb +22 -0
  29. data/app/services/pinnable/capture_pin.rb +28 -0
  30. data/app/services/pinnable/resolve_pin.rb +32 -0
  31. data/app/views/layouts/pinnable/application.html.erb +23 -0
  32. data/app/views/pinnable/_widget.html.erb +61 -0
  33. data/app/views/pinnable/pins/index.html.erb +66 -0
  34. data/config/importmap.rb +3 -0
  35. data/config/routes.rb +7 -0
  36. data/db/migrate/20260101000010_create_pinnable_pins.rb +37 -0
  37. data/db/migrate/20260101000030_create_pinnable_comments.rb +17 -0
  38. data/lib/generators/pinnable/install/install_generator.rb +27 -0
  39. data/lib/generators/pinnable/install/templates/pinnable.rb +23 -0
  40. data/lib/pinnable/configuration.rb +22 -0
  41. data/lib/pinnable/engine.rb +20 -0
  42. data/lib/pinnable/version.rb +3 -0
  43. data/lib/pinnable.rb +17 -0
  44. data/lib/tasks/pinnable_tasks.rake +4 -0
  45. metadata +113 -0
@@ -0,0 +1,8 @@
1
+ module Pinnable::Comment::Encryption
2
+ extend ActiveSupport::Concern
3
+
4
+ # Same opt-in as Pin — a reply can quote PII too.
5
+ included do
6
+ encrypts :body if Pinnable.config.encrypt
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module Pinnable::Comment::Relationships
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ belongs_to :pin, class_name: "Pinnable::Pin", inverse_of: :comments
6
+ belongs_to :author, polymorphic: true, optional: true
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Pinnable::Comment::Validations
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ validates :body, presence: true
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ module Pinnable
2
+ class Comment < ApplicationRecord
3
+ has_secure_token :public_id
4
+
5
+ include Comment::Relationships
6
+ include Comment::Validations
7
+ include Comment::Encryption
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ module Pinnable::Pin::Encryption
2
+ extend ActiveSupport::Concern
3
+
4
+ # Opt-in: hosts set `config.encrypt = true` (and configure Active Record encryption).
5
+ # Read at load — declared after `serialize :anchor`, so encryption wraps the JSON coder.
6
+ included do
7
+ encrypts :body, :anchor if Pinnable.config.encrypt
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ module Pinnable::Pin::Relationships
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ belongs_to :author, polymorphic: true, optional: true
6
+ belongs_to :tenant, polymorphic: true, optional: true
7
+ belongs_to :resolved_by, polymorphic: true, optional: true
8
+ has_many :comments, class_name: "Pinnable::Comment", dependent: :destroy, inverse_of: :pin
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ module Pinnable::Pin::Scopes
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ scope :for_url, ->(url) { where(url:) }
6
+ scope :recent, -> { order(created_at: :desc) }
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ module Pinnable::Pin::Transitions
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ enum :status, { open: 0, resolved: 1, wont_fix: 2 }, default: :open
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ module Pinnable::Pin::Validations
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ validates :url, presence: true
6
+ validates :body, presence: true
7
+ validate :anchor_within_limit
8
+ end
9
+
10
+ private
11
+
12
+ def anchor_within_limit
13
+ return if anchor.to_json.bytesize <= Pinnable.config.anchor_max_bytes
14
+
15
+ errors.add(:anchor, "is too large")
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ module Pinnable
2
+ class Pin < ApplicationRecord
3
+ has_secure_token :public_id
4
+ serialize :anchor, coder: JSON, type: Hash
5
+
6
+ include Pin::Relationships
7
+ include Pin::Validations
8
+ include Pin::Scopes
9
+ include Pin::Transitions
10
+ include Pin::Encryption
11
+ end
12
+ end
@@ -0,0 +1,27 @@
1
+ module Pinnable
2
+ # The wire shape the in-page overlay reads: enough to re-anchor (anchor blob) and to
3
+ # show the note, never the host's User object — only its captured label.
4
+ class MarkerSerializer
5
+ def initialize(pin) = @pin = pin
6
+
7
+ def call
8
+ {
9
+ public_id: pin.public_id,
10
+ url: pin.url,
11
+ body: pin.body,
12
+ status: pin.status,
13
+ author_label: pin.author_label,
14
+ anchor: pin.anchor,
15
+ comments: comments
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :pin
22
+
23
+ def comments
24
+ pin.comments.order(:created_at).map { |c| { author_label: c.author_label, body: c.body } }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ module Pinnable
2
+ # Appends a reply to a pin, stamping the author's display label like CapturePin does.
3
+ class AddComment
4
+ def initialize(pin:, author:, params:)
5
+ @pin = pin
6
+ @author = author
7
+ @params = params
8
+ end
9
+
10
+ def call
11
+ pin.comments.create!(
12
+ author:,
13
+ author_label: Pinnable.config.user_label.call(author),
14
+ body: params[:body]
15
+ )
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :pin, :author, :params
21
+ end
22
+ end
@@ -0,0 +1,28 @@
1
+ module Pinnable
2
+ # Turns a capture payload into a persisted Pin, stamping the author's display label
3
+ # (resolved through the host's `user_label`) so the inbox never needs the host's User.
4
+ class CapturePin
5
+ def initialize(author:, tenant:, params:)
6
+ @author = author
7
+ @tenant = tenant
8
+ @params = params
9
+ end
10
+
11
+ def call
12
+ Pin.create!(
13
+ author:,
14
+ tenant:,
15
+ author_label: Pinnable.config.user_label.call(author),
16
+ url: params[:url],
17
+ body: params[:body],
18
+ anchor:
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :author, :tenant, :params
25
+
26
+ def anchor = (params[:anchor] || {}).to_h
27
+ end
28
+ end
@@ -0,0 +1,32 @@
1
+ module Pinnable
2
+ # Moves a pin along its task lifecycle (open -> resolved/wont_fix and back), stamping
3
+ # who completed it and when, then emitting the host's audit event. Reopening clears
4
+ # the completion stamps so the task reads as live again.
5
+ class ResolvePin
6
+ COMPLETED = %w[resolved wont_fix].freeze
7
+
8
+ def initialize(pin:, by:, status:)
9
+ @pin = pin
10
+ @by = by
11
+ @status = status.to_s
12
+ end
13
+
14
+ def call
15
+ pin.update!(status:, **completion)
16
+ Pinnable.config.audit.call(pin, status.to_sym, by)
17
+ pin
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :pin, :by, :status
23
+
24
+ def completion
25
+ return { resolved_by: nil, resolved_by_label: nil, resolved_at: nil } unless completed?
26
+
27
+ { resolved_by: by, resolved_by_label: Pinnable.config.resolver_label.call(by), resolved_at: Time.current }
28
+ end
29
+
30
+ def completed? = COMPLETED.include?(status)
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Feedback · Pinnable</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= yield :head %>
9
+ <%= stylesheet_link_tag "pinnable/application", media: "all" %>
10
+ </head>
11
+ <body class="pinnable-body">
12
+ <header class="pinnable-topbar">
13
+ <span class="pinnable-topbar__dot">📌</span> Pinnable
14
+ </header>
15
+
16
+ <main class="pinnable-wrap">
17
+ <% if flash[:notice] %>
18
+ <div class="pinnable-flash"><%= flash[:notice] %></div>
19
+ <% end %>
20
+ <%= yield %>
21
+ </main>
22
+ </body>
23
+ </html>
@@ -0,0 +1,61 @@
1
+ <div
2
+ class="pinnable"
3
+ data-controller="pinnable"
4
+ data-pinnable-pins-url-value="<%= pinnable.pins_path %>"
5
+ data-pinnable-markers-url-value="<%= pinnable.markers_path %>"
6
+ data-pinnable-current-url-value="<%= request.path %>"
7
+ data-pinnable-focus-value="<%= params[:pinnable] %>"
8
+ data-pinnable-csrf-value="<%= form_authenticity_token %>"
9
+ >
10
+ <button
11
+ type="button"
12
+ class="pinnable-toggle"
13
+ data-pinnable-target="toggle"
14
+ data-action="pinnable#toggle"
15
+ >💬 Comments: Off</button>
16
+ </div>
17
+
18
+ <%= javascript_import_module_tag "pinnable" %>
19
+
20
+ <style>
21
+ .pinnable-toggle {
22
+ position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
23
+ padding: 8px 14px; border: 0; border-radius: 999px; cursor: pointer;
24
+ background: #1f2937; color: #fff; font: 500 13px system-ui, sans-serif;
25
+ box-shadow: 0 2px 8px rgba(0,0,0,.25);
26
+ }
27
+ .pinnable-toggle--on { background: #6366f1; }
28
+ .pinnable-composer {
29
+ position: absolute; z-index: 2147483001; width: 240px; padding: 10px;
30
+ background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
31
+ box-shadow: 0 6px 20px rgba(0,0,0,.18); font: 13px system-ui, sans-serif;
32
+ }
33
+ .pinnable-composer__text { width: 100%; min-height: 64px; box-sizing: border-box; resize: vertical; }
34
+ .pinnable-composer__actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
35
+ .pinnable-composer__actions button { cursor: pointer; padding: 4px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: #f9fafb; }
36
+ .pinnable-composer__save { background: #6366f1 !important; color: #fff; border-color: #6366f1 !important; }
37
+ .pinnable-marker {
38
+ position: absolute; z-index: 2147483000; transform: translate(-50%, -50%);
39
+ width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%;
40
+ background: #6366f1; color: #fff; font-size: 12px; line-height: 22px; cursor: pointer;
41
+ box-shadow: 0 1px 4px rgba(0,0,0,.3);
42
+ }
43
+ .pinnable-marker--flash { outline: 3px solid #f59e0b; outline-offset: 2px; }
44
+ .pinnable-pop {
45
+ position: absolute; z-index: 2147483001; width: 220px; padding: 10px;
46
+ background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
47
+ box-shadow: 0 6px 20px rgba(0,0,0,.18); font: 13px system-ui, sans-serif;
48
+ }
49
+ .pinnable-pop__resolve { cursor: pointer; margin-top: 8px; padding: 4px 10px; border-radius: 6px; border: 1px solid #6366f1; background: #6366f1; color: #fff; }
50
+ .pinnable-pop__thread { margin: 8px 0 6px; max-height: 140px; overflow-y: auto; }
51
+ .pinnable-pop__comment { font-size: 12px; line-height: 1.4; margin: 4px 0; }
52
+ .pinnable-pop__author { font-weight: 600; margin-right: 4px; color: #4338ca; }
53
+ .pinnable-pop__reply { margin: 6px 0 0; }
54
+ .pinnable-pop__input { width: 100%; box-sizing: border-box; padding: 5px 8px; border: 1px solid #d1d5db; border-radius: 6px; font: 12px system-ui, sans-serif; }
55
+ .pinnable-tray {
56
+ position: fixed; left: 16px; bottom: 16px; z-index: 2147483000; max-width: 260px;
57
+ padding: 10px; background: #fff; border: 1px solid #d1d5db; border-radius: 8px;
58
+ box-shadow: 0 2px 8px rgba(0,0,0,.2); font: 12px system-ui, sans-serif;
59
+ }
60
+ .pinnable-tray__item { display: block; width: 100%; text-align: left; cursor: pointer; margin-top: 6px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; padding: 4px 8px; }
61
+ </style>
@@ -0,0 +1,66 @@
1
+ <section class="pinnable-inbox">
2
+ <div class="pinnable-head">
3
+ <h1>Feedback<% if @pins.any? %><span class="pinnable-count"><%= @pins.size %></span><% end %></h1>
4
+ <p>Comments left across the app. Open one to jump back to the exact element it was pinned on.</p>
5
+ </div>
6
+
7
+ <% if @pins.empty? %>
8
+ <div class="pinnable-card">
9
+ <div class="pinnable-empty">
10
+ <strong>No feedback yet</strong>
11
+ Toggle comment mode on any page, then click an element to leave the first note.
12
+ </div>
13
+ </div>
14
+ <% else %>
15
+ <div class="pinnable-card">
16
+ <table class="pinnable-table">
17
+ <thead>
18
+ <tr>
19
+ <th>Comment</th>
20
+ <th>Page</th>
21
+ <th>By</th>
22
+ <th>Status</th>
23
+ <th>When</th>
24
+ <th></th>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ <% @pins.each do |pin| %>
29
+ <tr class="<%= "is-done" unless pin.open? %>">
30
+ <td class="pinnable-cell-body">
31
+ <%= pin.body %>
32
+ <% if pin.comments.any? %><span class="pinnable-replies">💬 <%= pin.comments.size %></span><% end %>
33
+ </td>
34
+ <td class="pinnable-cell-muted"><%= pin.url %></td>
35
+ <td class="pinnable-cell-muted"><%= pin.author_label %></td>
36
+ <td><span class="pinnable-status pinnable-status--<%= pin.status %>"><%= pin.status.tr("_", " ") %></span></td>
37
+ <td class="pinnable-cell-muted"><%= time_ago_in_words(pin.created_at) %> ago</td>
38
+ <td>
39
+ <div class="pinnable-actions">
40
+ <%= link_to "Open on page", pin_path(pin.public_id), class: "pinnable-btn" %>
41
+ <% if pin.open? %>
42
+ <%=
43
+ button_to "Resolve", pin_path(pin.public_id),
44
+ method: :patch,
45
+ params: { pin: { status: "resolved" } },
46
+ form_class: "pinnable-resolve",
47
+ class: "pinnable-btn pinnable-btn--primary"
48
+ %>
49
+ <% else %>
50
+ <%=
51
+ button_to "Reopen", pin_path(pin.public_id),
52
+ method: :patch,
53
+ params: { pin: { status: "open" } },
54
+ form_class: "pinnable-resolve",
55
+ class: "pinnable-btn"
56
+ %>
57
+ <% end %>
58
+ </div>
59
+ </td>
60
+ </tr>
61
+ <% end %>
62
+ </tbody>
63
+ </table>
64
+ </div>
65
+ <% end %>
66
+ </section>
@@ -0,0 +1,3 @@
1
+ # Single self-contained entrypoint. It imports only "@hotwired/stimulus" (which the
2
+ # host already pins), so there are no further pins to resolve.
3
+ pin "pinnable", to: "pinnable.js", preload: true
data/config/routes.rb ADDED
@@ -0,0 +1,7 @@
1
+ Pinnable::Engine.routes.draw do
2
+ resources :pins, only: %i[index show create update], param: :public_id do
3
+ resources :comments, only: :create
4
+ end
5
+ root to: "pins#index"
6
+ resources :markers, only: :index
7
+ end
@@ -0,0 +1,37 @@
1
+ # Portable types only (string/text/integer/datetime) — no JSONB — so the engine runs
2
+ # identically on SQLite, MySQL, and Postgres. The anchor blob is JSON-serialized text.
3
+ # author/tenant/resolved_by are polymorphic with string id columns to tolerate any host
4
+ # primary-key type (bigint or uuid).
5
+ class CreatePinnablePins < ActiveRecord::Migration[8.0]
6
+ def change
7
+ create_table :pinnable_pins do |t|
8
+ t.string :public_id, null: false
9
+
10
+ t.string :author_type
11
+ t.string :author_id
12
+ t.string :author_label
13
+
14
+ t.string :tenant_type
15
+ t.string :tenant_id
16
+
17
+ t.string :url, null: false
18
+ t.text :body
19
+ t.text :anchor
20
+
21
+ t.integer :status, null: false, default: 0
22
+ t.string :resolved_by_type
23
+ t.string :resolved_by_id
24
+ t.string :resolved_by_label
25
+ t.datetime :resolved_at
26
+
27
+ t.string :user_agent
28
+
29
+ t.timestamps
30
+ end
31
+
32
+ add_index :pinnable_pins, :public_id, unique: true
33
+ add_index :pinnable_pins, %i[tenant_type tenant_id]
34
+ add_index :pinnable_pins, :url
35
+ add_index :pinnable_pins, :status
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ # Replies on a pin — the conversation thread. Portable types; body encrypts at rest when
2
+ # the host opts into config.encrypt (it can quote PII just like a pin body).
3
+ class CreatePinnableComments < ActiveRecord::Migration[8.0]
4
+ def change
5
+ create_table :pinnable_comments do |t|
6
+ t.references :pin, null: false, foreign_key: { to_table: :pinnable_pins }
7
+ t.string :public_id, null: false
8
+ t.string :author_type
9
+ t.string :author_id
10
+ t.string :author_label
11
+ t.text :body
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :pinnable_comments, :public_id, unique: true
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ require "rails/generators/base"
2
+
3
+ module Pinnable
4
+ module Generators
5
+ # `rails generate pinnable:install` — drops the config initializer and mounts the
6
+ # engine. Migrations are installed separately via `rails pinnable:install:migrations`
7
+ # (the standard engine task), kept out of here so the schema stays single-sourced.
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def create_initializer
12
+ template "pinnable.rb", "config/initializers/pinnable.rb"
13
+ end
14
+
15
+ def mount_engine
16
+ route 'mount Pinnable::Engine => "/pinnable"'
17
+ end
18
+
19
+ def show_post_install
20
+ say ""
21
+ say "Pinnable installed. Two steps left:", :green
22
+ say " 1. bin/rails pinnable:install:migrations && bin/rails db:migrate"
23
+ say " 2. Add <%= pinnable_widget %> to your layout, just before </body>."
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # Pinnable configuration. Everything app-specific lives here; the engine assumes none of it.
2
+ Pinnable.configure do |c|
3
+ # The gate. Return false and the widget never renders and every endpoint 404s.
4
+ c.enabled_for = ->(user) { user&.admin? }
5
+
6
+ # How to find the current user from a controller (whatever your auth uses).
7
+ c.current_user = ->(controller) { controller.current_user }
8
+
9
+ # How to label a user in the inbox. No host User object is stored — only this label.
10
+ c.user_label = ->(user) { user.try(:email) || user.try(:name) || user.to_s }
11
+
12
+ # Engine controllers inherit this, picking up your auth, CSRF, and layout.
13
+ c.parent_controller = "ApplicationController"
14
+
15
+ # Optional: scope pins to a tenant (account/org). Return nil for a single-tenant app.
16
+ # c.tenant_scope = ->(controller) { controller.current_account }
17
+
18
+ # Optional: a sink for status changes (open -> resolved/wont_fix and back).
19
+ # c.audit = ->(pin, event, by) { Rails.logger.info("pinnable #{event} #{pin.public_id}") }
20
+
21
+ # Optional: encrypt body + anchor at rest (requires Active Record encryption configured).
22
+ # c.encrypt = true
23
+ end
@@ -0,0 +1,22 @@
1
+ module Pinnable
2
+ # The single seam between the engine and its host. Defaults are safe-off: with no
3
+ # configuration the widget never renders and every endpoint 404s, so a host opts in
4
+ # deliberately rather than by accident.
5
+ class Configuration
6
+ attr_accessor :enabled_for, :current_user, :tenant_scope, :user_label,
7
+ :resolver_label, :parent_controller, :encrypt, :audit, :anchor_max_bytes, :layout
8
+
9
+ def initialize
10
+ @enabled_for = ->(_user) { false } # the gate
11
+ @current_user = ->(_controller) { nil } # host's auth
12
+ @tenant_scope = ->(_controller) { nil } # optional multitenancy
13
+ @user_label = ->(user) { user.try(:email) || user.try(:name) || user.to_s }
14
+ @resolver_label = @user_label
15
+ @parent_controller = "ActionController::Base" # inherit host auth/CSRF/layout
16
+ @encrypt = false # opt-in AR encryption of body/anchor
17
+ @audit = ->(_pin, _event, _by) {} # optional sink
18
+ @anchor_max_bytes = 50_000
19
+ @layout = "pinnable/application" # host sets its own for native chrome
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ module Pinnable
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Pinnable
4
+
5
+ # Expose the host-facing helper (`<%= pinnable_widget %>`) in the host's views,
6
+ # regardless of the host's `include_all_helpers` setting.
7
+ initializer "pinnable.helpers" do
8
+ ActiveSupport.on_load(:action_controller_base) { helper Pinnable::WidgetHelper }
9
+ end
10
+
11
+ # Merge the engine's pins into the host import map so `import "pinnable"` resolves.
12
+ # No-op on hosts that don't use importmap-rails (they include the asset themselves).
13
+ initializer "pinnable.importmap", before: "importmap" do |app|
14
+ next unless app.config.respond_to?(:importmap)
15
+
16
+ app.config.importmap.paths << root.join("config/importmap.rb")
17
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,3 @@
1
+ module Pinnable
2
+ VERSION = "0.1.0"
3
+ end
data/lib/pinnable.rb ADDED
@@ -0,0 +1,17 @@
1
+ require "pinnable/version"
2
+ require "pinnable/configuration"
3
+ require "pinnable/engine"
4
+
5
+ # Pinnable — a host-agnostic, mountable visual-feedback layer. Enable it for some
6
+ # users, flip a toggle, click any element on any page, leave a note. Notes remember
7
+ # who/where/which-element, re-anchor on reload, and are worked like a task list.
8
+ #
9
+ # Everything the host differs on — auth, the gate, multitenancy, the audit sink — is
10
+ # injected through `Pinnable.config`, so the engine carries no app-specific coupling.
11
+ module Pinnable
12
+ class << self
13
+ def config = @config ||= Configuration.new
14
+ def configure = yield config
15
+ def reset_config! = @config = Configuration.new
16
+ end
17
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :pinnable do
3
+ # # Task goes here
4
+ # end