redpen-rails 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 39dd26115a7ab4e0acfeda34e39d2a3f663d441d3f7301985df839bc1bef8889
4
+ data.tar.gz: be286165894bce577539b1ab7438f2b9041d8c90771a9cc535b310a5616f1552
5
+ SHA512:
6
+ metadata.gz: 9db7f16ee197699140d766ea670df403bc51c24065e3ba1d618a92a2bd052df93c77158eaaf4e5d5dd6332f64d6ed9dd86a4ed215cbf15443570b89a389baecf
7
+ data.tar.gz: 1ca4e0ff51280541db1c08eb9928f32890acee01d177b6492f18ac5ed80ecb7ad692e2506cda6f96a583577f584977514fce5d95c77f82cee1d7a168345d8e6a
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-09-16
4
+
5
+ First extraction, from nityesh.com. Notes pinned to elements by path + CSS selector, a pen UI as one Stimulus controller, resolve/reopen as a resolution resource, `redpen_rail` and `redpen_inject` helpers, `redpen:install` and `redpen:views` generators. Sprockets and Propshaft, import maps only.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # Red pen
2
+
3
+ Click any element of a page, say what should change. The feedback layer for AI-made pages, as a Rails engine.
4
+
5
+ A note is not a chat message. It carries **where** on the page (the path and a CSS selector), **what was there** (a text snippet), and **what should change** (the note). That is exactly what a model, or a person, needs to make a surgical edit without seeing the page. The host app reads open notes, does the work, and resolves each with one line that shows up under the note on the page.
6
+
7
+ The gem captures. The app decides. No auth, no AI, no page model.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "redpen-rails"
14
+ ```
15
+
16
+ ```sh
17
+ bin/rails generate redpen:install # migration, initializer, mount line
18
+ bin/rails db:migrate
19
+ ```
20
+
21
+ ```ruby
22
+ # config/initializers/redpen.rb
23
+ Redpen.author = -> { Current.user } # or current_user with Devise
24
+ Redpen.annotatable = ->(path, author) { true } # who may pen which path
25
+ ```
26
+
27
+ ```erb
28
+ <%# app/views/layouts/application.html.erb, last child of <body> %>
29
+ <%= redpen_rail if signed_in? %>
30
+ ```
31
+
32
+ That's the install. Signed in, every page has a "Red pen" pill bottom-right. Click it, click an element, type, save. A numbered pin lands on the element. Click a pin to read the note, resolve it, reopen it, or delete it. Readers get nothing: no markup, no stylesheet, no JavaScript.
33
+
34
+ ## The two questions
35
+
36
+ Both lambdas run inside the engine's controller, so anything a controller can see works in them.
37
+
38
+ | | Asked | Default |
39
+ |---|---|---|
40
+ | `Redpen.author` | Who is holding the pen? Becomes every note's author. Nil means 403. | `-> { nil }` |
41
+ | `Redpen.annotatable` | May this author pen this path? Asked once per request, about that path. | `->(path, author) { true }` |
42
+
43
+ Notes are read **by path**, not by author. Two admins of one page see the same notes; the author column is attribution.
44
+
45
+ `Redpen.parent_controller` (default `"ApplicationController"`) is what the engine's controllers inherit from, so your authentication runs first.
46
+
47
+ ## Reading notes back
48
+
49
+ ```ruby
50
+ Redpen::Note.open.on("/communities/42/") # path, selector, snippet, body
51
+ note.resolve("Cut the headline to six words.")
52
+ note.reopen
53
+ ```
54
+
55
+ If the host has an agent or an MCP server, it writes its own tool over these two verbs. Scope it through whatever owns the page, never `Redpen::Note.all`: in a multi-tenant app that would hand one tenant every other tenant's notes.
56
+
57
+ ## Documents served verbatim
58
+
59
+ For a page with its own `<head>` and no layout, splice the rail in at serve time:
60
+
61
+ ```ruby
62
+ render html: helpers.redpen_inject(document).html_safe, layout: false
63
+ ```
64
+
65
+ ## Restyling
66
+
67
+ Every colour and font is a CSS variable (`--rp-paper`, `--rp-red`, `--rp-mono`, …). Set them on `:root` in your own stylesheet; the gem's defaults sit in a cascade layer, so yours win whatever the load order. For deeper changes, `bin/rails generate redpen:views` copies the templates into `app/views/redpen/notes`, where they take precedence.
68
+
69
+ ## Pins that survive rewrites
70
+
71
+ A pin follows its selector, and the selector short-circuits at the nearest ancestor with an `id`. If a generator stamps ids on block elements and preserves them on revision, pins survive a full rewrite. When a selector no longer matches, the snippet re-anchors the note to the same kind of element whose text still starts the same way. When nothing matches, the note lands in an "Element gone" panel with its snippet, still readable and resolvable.
72
+
73
+ ## Requirements and non-goals
74
+
75
+ Rails 8.0+, import maps (importmap-rails), Turbo and Stimulus. Sprockets and Propshaft both work. Not in v1: esbuild/webpack hosts, threads on notes, visitor comments, generating or hosting pages.
76
+
77
+ ## Development
78
+
79
+ ```sh
80
+ bundle install
81
+ bin/rails db:migrate
82
+ bin/rails test # models, controllers, integration
83
+ bin/rails test test/system # a headless Chrome clicks the pen
84
+ BUNDLE_GEMFILE=gemfiles/sprockets.gemfile bin/rails test
85
+ ```
86
+
87
+ ## License
88
+
89
+ MIT. Extracted from [nityesh.com](https://nityesh.com), first hosted for real by [Curated Connections](https://curatedconnections.io).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,200 @@
1
+ // The red pen, in one module. Pulled in by the rail alone, so a page without the rail never
2
+ // asks for it. Imports Turbo because on a verbatim document the host's own entry never runs;
3
+ // on a page that already has Turbo the import map hands back the same module.
4
+ //
5
+ // The controller knows nothing about notes: it pins whatever carries a data-note-selector,
6
+ // captures a clicked element into two hidden fields, and submits a form. Everything the
7
+ // server needs travels in the form; everything the page needs to show comes back as HTML.
8
+ // Pin coordinates are never stored. They're measured from the element on every pin().
9
+ import "@hotwired/turbo-rails"
10
+ import { Application, Controller } from "@hotwired/stimulus"
11
+
12
+ class RedpenController extends Controller {
13
+ static targets = [ "toggle", "toggleLabel", "highlight", "layer", "notes", "note", "orphans",
14
+ "composer", "composerTarget", "selector", "snippet", "body" ]
15
+
16
+ connect() {
17
+ this.hover = this.hover.bind(this)
18
+ this.capture = this.capture.bind(this)
19
+ }
20
+
21
+ disconnect() {
22
+ this.deactivate()
23
+ }
24
+
25
+ // ---- pins -------------------------------------------------------------------------
26
+
27
+ pin() {
28
+ if (!this.hasLayerTarget) return
29
+ this.layerTarget.querySelectorAll(".redpen-pin").forEach(pin => pin.remove())
30
+ const orphanList = this.orphansTarget.querySelector("ol")
31
+ orphanList.replaceChildren()
32
+
33
+ let number = 0
34
+ for (const note of this.noteTargets) {
35
+ const element = this.resolve(note.dataset.noteSelector, note.dataset.noteSnippet)
36
+ note.querySelector(".redpen-note__number").textContent = ++number
37
+ if (element) {
38
+ this.notesTarget.appendChild(note)
39
+ this.layerTarget.appendChild(this.pinFor(note, element, number))
40
+ } else {
41
+ orphanList.appendChild(note)
42
+ }
43
+ }
44
+ this.orphansTarget.toggleAttribute("data-populated", orphanList.children.length > 0)
45
+ }
46
+
47
+ pinFor(note, element, number) {
48
+ const pin = document.createElement("button")
49
+ pin.type = "button"
50
+ pin.className = "redpen-pin"
51
+ pin.textContent = number
52
+ pin.title = note.querySelector(".redpen-note__body").textContent
53
+ if (note.classList.contains("redpen-note--resolved")) pin.classList.add("redpen-pin--resolved")
54
+ const { x, y } = this.documentPoint(element.getBoundingClientRect())
55
+ pin.style.left = `${x - 12}px`
56
+ pin.style.top = `${y - 12}px`
57
+ pin.addEventListener("click", event => { event.stopPropagation(); this.open(note, pin) })
58
+ return pin
59
+ }
60
+
61
+ // The selector first. When the page has been rewritten under the note, the snippet:
62
+ // the same kind of element whose text still starts the way it did.
63
+ resolve(selector, snippet) {
64
+ try { const element = document.querySelector(selector); if (element) return element } catch {}
65
+ if (!snippet || snippet.length < 12) return null
66
+ const tag = selector.split(">").pop().trim().split(":")[0]
67
+ const candidates = document.querySelectorAll(tag.startsWith("#") ? "*" : tag)
68
+ let match = null
69
+ for (const element of candidates) {
70
+ if (element.closest(".redpen") || !this.textOf(element).startsWith(snippet)) continue
71
+ if (!match || element.textContent.length < match.textContent.length) match = element
72
+ }
73
+ return match
74
+ }
75
+
76
+ open(note, anchor) {
77
+ this.close()
78
+ const rect = anchor.getBoundingClientRect()
79
+ this.place(note, rect.right + 8, rect.top)
80
+ }
81
+
82
+ // ---- pen mode ---------------------------------------------------------------------
83
+
84
+ toggle() {
85
+ this.element.hasAttribute("data-redpen-active") ? this.deactivate() : this.activate()
86
+ }
87
+
88
+ activate() {
89
+ this.close()
90
+ this.element.setAttribute("data-redpen-active", "")
91
+ this.toggleLabelTarget.textContent = "Click an element"
92
+ document.addEventListener("pointermove", this.hover, true)
93
+ document.addEventListener("click", this.capture, true)
94
+ }
95
+
96
+ deactivate() {
97
+ this.element.removeAttribute("data-redpen-active")
98
+ if (this.hasToggleLabelTarget) this.toggleLabelTarget.textContent = "Red pen"
99
+ if (this.hasHighlightTarget) this.highlightTarget.removeAttribute("data-visible")
100
+ document.removeEventListener("pointermove", this.hover, true)
101
+ document.removeEventListener("click", this.capture, true)
102
+ }
103
+
104
+ hover(event) {
105
+ const element = this.targetOf(event)
106
+ if (!element) { this.highlightTarget.removeAttribute("data-visible"); return }
107
+ const rect = element.getBoundingClientRect()
108
+ Object.assign(this.highlightTarget.style, {
109
+ left: `${rect.left}px`, top: `${rect.top}px`, width: `${rect.width}px`, height: `${rect.height}px`
110
+ })
111
+ this.highlightTarget.setAttribute("data-visible", "")
112
+ }
113
+
114
+ capture(event) {
115
+ const element = this.targetOf(event)
116
+ if (!element) return
117
+ event.preventDefault()
118
+ event.stopPropagation()
119
+ this.deactivate()
120
+
121
+ const snippet = this.textOf(element).slice(0, 120)
122
+ this.selectorTarget.value = this.cssPath(element)
123
+ this.snippetTarget.value = snippet
124
+ this.composerTargetTarget.textContent = snippet || `<${element.tagName.toLowerCase()}>`
125
+ this.bodyTarget.value = ""
126
+ this.place(this.composerTarget, event.clientX + 12, event.clientY + 12)
127
+ this.bodyTarget.focus()
128
+ }
129
+
130
+ // The page's own elements only, never our UI. The body counts: a note on the body is
131
+ // a note on the whole page ("add a testimonials section").
132
+ targetOf(event) {
133
+ const element = event.target
134
+ if (!(element instanceof Element)) return null
135
+ if (element.closest(".redpen")) return null
136
+ if (element === document.documentElement) return null
137
+ return element
138
+ }
139
+
140
+ submit(event) {
141
+ event.preventDefault()
142
+ this.composerTarget.requestSubmit()
143
+ }
144
+
145
+ close() {
146
+ for (const open of this.element.querySelectorAll("[data-open]")) open.removeAttribute("data-open")
147
+ }
148
+
149
+ stop(event) {
150
+ event.stopPropagation()
151
+ }
152
+
153
+ keydown(event) {
154
+ if (event.key === "Escape") { this.deactivate(); this.close() }
155
+ }
156
+
157
+ // ---- geometry ---------------------------------------------------------------------
158
+
159
+ // Viewport point → coordinates inside the layer, whatever positions the layer.
160
+ documentPoint({ left, top }) {
161
+ const origin = this.layerTarget.getBoundingClientRect()
162
+ return { x: left - origin.left, y: top - origin.top }
163
+ }
164
+
165
+ place(box, clientX, clientY) {
166
+ box.setAttribute("data-open", "")
167
+ const width = box.offsetWidth, height = box.offsetHeight
168
+ const clientLeft = Math.max(8, Math.min(clientX, window.innerWidth - width - 8))
169
+ const clientTop = Math.max(8, Math.min(clientY, window.innerHeight - height - 8))
170
+ const { x, y } = this.documentPoint({ left: clientLeft, top: clientTop })
171
+ box.style.left = `${x}px`
172
+ box.style.top = `${y}px`
173
+ }
174
+
175
+ // ---- text and selector ------------------------------------------------------------
176
+
177
+ textOf(element) {
178
+ return (element.textContent || "").replace(/\s+/g, " ").trim()
179
+ }
180
+
181
+ // Up from the element to the nearest id, or to the body. An id short-circuits the
182
+ // chain, which is what lets a pin survive a rewrite of everything around it.
183
+ cssPath(element) {
184
+ const parts = []
185
+ let node = element
186
+ while (node && node.nodeType === 1 && node !== document.body && node !== document.documentElement) {
187
+ if (node.id) { parts.unshift(`#${CSS.escape(node.id)}`); break }
188
+ const siblings = Array.from(node.parentNode?.children || []).filter(sibling => sibling.tagName === node.tagName)
189
+ const index = siblings.indexOf(node) + 1
190
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-of-type(${index})`)
191
+ node = node.parentNode
192
+ }
193
+ if (!parts.length || !parts[0].startsWith("#")) parts.unshift("body")
194
+ return parts.join(" > ")
195
+ }
196
+ }
197
+
198
+ // The pen's own Stimulus application. A host's application, if any, ignores the
199
+ // "redpen" identifier the way this one ignores the host's.
200
+ Application.start().register("redpen", RedpenController)
@@ -0,0 +1,117 @@
1
+ /* The red pen. Carries its own tokens and never leans on the host's stylesheet. The
2
+ defaults live in a cascade layer, so a host's own `:root { --rp-red: ... }` wins no
3
+ matter which stylesheet loads last. Everything sits above the page (z-index) and
4
+ outside its flow (display: contents on the frame). */
5
+
6
+ @layer redpen {
7
+ :root {
8
+ --rp-paper: #fbf6ea;
9
+ --rp-paper-deep: #efe6d2;
10
+ --rp-ink: #2b2620;
11
+ --rp-ink-soft: #5c5347;
12
+ --rp-line: #d9cdb4;
13
+ --rp-red: #b05c34;
14
+ --rp-red-deep: #96491f;
15
+ --rp-mono: ui-monospace, Menlo, Consolas, monospace;
16
+ --rp-shadow: 0 6px 24px rgba(43, 38, 32, .22);
17
+ --rp-radius: 8px;
18
+ }
19
+ }
20
+
21
+ .redpen { display: contents; }
22
+ .redpen *, .redpen *::before, .redpen *::after { box-sizing: border-box; }
23
+
24
+ html:has(.redpen[data-redpen-active]) body { cursor: crosshair; }
25
+
26
+ /* Toggle pill, bottom-right. */
27
+ .redpen-toggle {
28
+ position: fixed; right: 18px; bottom: 18px; z-index: 2147483001;
29
+ display: inline-flex; align-items: center; gap: 8px;
30
+ padding: 9px 14px; border-radius: 999px;
31
+ border: 1px solid var(--rp-red-deep); background: var(--rp-paper); color: var(--rp-red-deep);
32
+ font: 500 12px/1 var(--rp-mono); letter-spacing: .04em; text-transform: uppercase;
33
+ cursor: pointer; box-shadow: var(--rp-shadow);
34
+ }
35
+ .redpen-toggle:hover { background: var(--rp-paper-deep); }
36
+ .redpen[data-redpen-active] .redpen-toggle { background: var(--rp-red); border-color: var(--rp-red); color: var(--rp-paper); }
37
+ .redpen-toggle__dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
38
+
39
+ /* Hover box while picking an element. */
40
+ .redpen-highlight {
41
+ position: fixed; z-index: 2147482900; pointer-events: none; display: none;
42
+ border: 2px solid var(--rp-red); border-radius: 3px; background: color-mix(in srgb, var(--rp-red) 10%, transparent);
43
+ }
44
+ .redpen-highlight[data-visible] { display: block; }
45
+
46
+ /* Document-coordinate layer for pins, popovers and the composer. */
47
+ .redpen-layer { position: absolute; top: 0; left: 0; width: 0; height: 0; overflow: visible; z-index: 2147482950; }
48
+ .redpen-notes { list-style: none; margin: 0; padding: 0; }
49
+
50
+ .redpen-pin {
51
+ position: absolute; width: 24px; height: 24px; padding: 0;
52
+ border-radius: 50% 50% 50% 2px; border: 1px solid var(--rp-red-deep);
53
+ background: var(--rp-red); color: var(--rp-paper);
54
+ font: 700 11px/1 var(--rp-mono); display: flex; align-items: center; justify-content: center;
55
+ cursor: pointer; box-shadow: 0 1px 6px rgba(43, 38, 32, .35);
56
+ }
57
+ .redpen-pin:hover { background: var(--rp-red-deep); }
58
+ .redpen-pin--resolved { background: var(--rp-paper-deep); color: var(--rp-ink-soft); border-color: var(--rp-line); }
59
+
60
+ /* A note: hidden until a pin opens it (popover) or it lands in the orphans panel (row). */
61
+ .redpen-note {
62
+ display: none; width: 280px; max-width: calc(100vw - 16px);
63
+ padding: 12px; border: 1px solid var(--rp-line); border-radius: var(--rp-radius);
64
+ background: var(--rp-paper); color: var(--rp-ink); box-shadow: var(--rp-shadow);
65
+ font: 400 13px/1.5 var(--rp-mono);
66
+ }
67
+ .redpen-layer .redpen-note[data-open] { display: block; position: absolute; z-index: 2147483002; }
68
+ .redpen-note__head { display: flex; gap: 8px; align-items: baseline; margin-bottom: 8px; color: var(--rp-ink-soft); font-size: 11px; }
69
+ .redpen-note__number { flex: none; color: var(--rp-red-deep); font-weight: 700; }
70
+ .redpen-note__number::before { content: "#"; }
71
+ .redpen-note__snippet { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-style: italic; }
72
+ .redpen-note__body { margin: 0 0 8px; white-space: pre-wrap; word-break: break-word; }
73
+ .redpen-note__resolution { margin: 0 0 8px; padding: 8px; border-radius: 5px; background: var(--rp-paper-deep); font-size: 12px; }
74
+ .redpen-note__label { display: block; color: var(--rp-red-deep); font-size: 10px; letter-spacing: .06em; text-transform: uppercase; margin-bottom: 3px; }
75
+ .redpen-note__meta { font-size: 10px; color: var(--rp-ink-soft); }
76
+ .redpen-note--resolved .redpen-note__body { color: var(--rp-ink-soft); }
77
+ .redpen-note__actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; }
78
+ .redpen-note__actions form { margin: 0; }
79
+
80
+ .redpen-btn {
81
+ padding: 5px 10px; border-radius: 5px; border: 1px solid var(--rp-line);
82
+ background: var(--rp-paper); color: var(--rp-ink); font: 500 11px/1 var(--rp-mono); cursor: pointer;
83
+ }
84
+ .redpen-btn:hover { background: var(--rp-paper-deep); }
85
+ .redpen-btn--primary { background: var(--rp-red); border-color: var(--rp-red-deep); color: var(--rp-paper); }
86
+ .redpen-btn--primary:hover { background: var(--rp-red-deep); }
87
+ .redpen-btn--quiet { border-color: transparent; color: var(--rp-ink-soft); }
88
+
89
+ /* Composer: the new-note form, placed where the author clicked. */
90
+ .redpen-composer {
91
+ display: none; position: absolute; z-index: 2147483002; width: 280px; max-width: calc(100vw - 16px);
92
+ padding: 12px; border: 1px solid var(--rp-line); border-radius: var(--rp-radius);
93
+ background: var(--rp-paper); box-shadow: var(--rp-shadow); font: 400 13px/1.5 var(--rp-mono);
94
+ }
95
+ .redpen-composer[data-open] { display: block; }
96
+ .redpen-composer__target { margin-bottom: 8px; color: var(--rp-ink-soft); font-size: 11px; font-style: italic; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
97
+ .redpen-composer textarea {
98
+ display: block; width: 100%; resize: vertical; margin: 0; padding: 8px;
99
+ border: 1px solid var(--rp-line); border-radius: 5px; background: #fff; color: var(--rp-ink);
100
+ font: 400 13px/1.5 var(--rp-mono); outline: none;
101
+ }
102
+ .redpen-composer textarea:focus { border-color: var(--rp-red); }
103
+
104
+ /* Orphans: notes whose element no longer exists. Visible only when populated. */
105
+ .redpen-orphans {
106
+ display: none; position: fixed; right: 18px; bottom: 64px; z-index: 2147483001;
107
+ width: 300px; max-height: 50vh; overflow: auto; padding: 10px;
108
+ border: 1px solid var(--rp-line); border-radius: var(--rp-radius); background: var(--rp-paper); box-shadow: var(--rp-shadow);
109
+ }
110
+ .redpen-orphans[data-populated] { display: block; }
111
+ .redpen-orphans__title { margin: 0 0 8px; color: var(--rp-red-deep); font: 700 10px/1 var(--rp-mono); letter-spacing: .08em; text-transform: uppercase; }
112
+ .redpen-orphans .redpen-note { display: block; width: auto; box-shadow: none; margin-bottom: 8px; }
113
+
114
+ @media (max-width: 640px) {
115
+ .redpen-toggle { right: 12px; bottom: 12px; }
116
+ .redpen-orphans { right: 12px; left: 12px; width: auto; }
117
+ }
@@ -0,0 +1,23 @@
1
+ module Redpen
2
+ # Inherits from the host's controller so its before_actions run first; then asks the
3
+ # host the two questions. Every request here is about one path, and the gate is asked
4
+ # once, about that path. Reads are by path, never by author: two admins of one page
5
+ # see the same notes.
6
+ class ApplicationController < Redpen.parent_controller.constantize
7
+ include HostRouteHelpers
8
+
9
+ # The index answers a <turbo-frame> inside the host's page; a layout would nest one.
10
+ layout false
11
+
12
+ before_action :set_author, :authorize_path
13
+
14
+ private
15
+ def set_author
16
+ Current.author = instance_exec(&Redpen.author)
17
+ end
18
+
19
+ def authorize_path
20
+ head :forbidden unless Current.author && instance_exec(path, Current.author, &Redpen.annotatable)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,37 @@
1
+ module Redpen
2
+ # The notes on one page. Every write redirects back to the index, which is the whole
3
+ # rail: pins, popovers, the composer. One renderer, so a note looks the same whether
4
+ # it arrived with the page or a second ago.
5
+ class NotesController < ApplicationController
6
+ def index
7
+ @notes = notes.ordered
8
+ end
9
+
10
+ def create
11
+ notes.create!(note_params)
12
+ redirect_to notes_path(path: path), status: :see_other
13
+ end
14
+
15
+ def destroy
16
+ note.destroy
17
+ redirect_to notes_path(path: path), status: :see_other
18
+ end
19
+
20
+ private
21
+ def notes
22
+ Note.on(path)
23
+ end
24
+
25
+ def note
26
+ @note ||= Note.find(params[:id])
27
+ end
28
+
29
+ def path
30
+ @path ||= params[:path].presence || params.dig(:note, :path).presence || note.path
31
+ end
32
+
33
+ def note_params
34
+ params.expect(note: %i[ path selector snippet body ])
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ module Redpen
2
+ # Resolving a note is a noun with a lifecycle, not a verb on the note: create resolves,
3
+ # destroy reopens. A host's agent calls the same model verb.
4
+ class ResolutionsController < ApplicationController
5
+ def create
6
+ note.resolve(params[:resolution])
7
+ redirect_to notes_path(path: path), status: :see_other
8
+ end
9
+
10
+ def destroy
11
+ note.reopen
12
+ redirect_to notes_path(path: path), status: :see_other
13
+ end
14
+
15
+ private
16
+ def note
17
+ @note ||= Note.find(params[:note_id])
18
+ end
19
+
20
+ def path
21
+ note.path
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ module Redpen
2
+ module RailHelper
3
+ # The pen on a page the host lays out. One line in the layout, for whoever may pen:
4
+ #
5
+ # <%= redpen_rail if signed_in? %>
6
+ #
7
+ # Render it as the last child of <body>: an ancestor with transform or overflow
8
+ # would clip the pins.
9
+ def redpen_rail(path: request.path)
10
+ render "redpen/notes/rail", path: path
11
+ end
12
+
13
+ # The pen on a document the host serves verbatim, with its own <head> and no layout.
14
+ # Splices the rail before </body>, or appends it when the document has none. From a
15
+ # controller: `render html: helpers.redpen_inject(document).html_safe, layout: false`.
16
+ def redpen_inject(document, path: request.path)
17
+ rail = render("redpen/notes/standalone", path: path)
18
+ document.match?(%r{</body>}i) ? document.sub(%r{</body>}i) { "#{rail}</body>" } : document + rail
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ module Redpen
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ module Redpen
2
+ # Who is holding the pen this request, answered once by Redpen.author.
3
+ class Current < ActiveSupport::CurrentAttributes
4
+ attribute :author
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ module Redpen
2
+ # A note pinned to one element of one page: "this runs long", "swap this image". The
3
+ # page is its path and the element is a CSS selector, nothing else identifies them, so
4
+ # the same table serves any page a host renders and the pin lands wherever the element
5
+ # is when the page next paints. The snippet is what the element said at the time, for
6
+ # re-anchoring and for whoever reads the note without the page in front of them.
7
+ class Note < ApplicationRecord
8
+ belongs_to :author, polymorphic: true, default: -> { Current.author }
9
+
10
+ validates :path, :selector, :body, presence: true
11
+ validates :path, format: { with: %r{\A/\S*\z}, message: "must be a site path" }
12
+
13
+ scope :open, -> { where(resolved_at: nil) }
14
+ scope :resolved, -> { where.not(resolved_at: nil) }
15
+ scope :on, ->(path) { where(path: path) }
16
+ scope :ordered, -> { order(:created_at) }
17
+
18
+ def resolve(resolution = nil)
19
+ update!(resolved_at: Time.current, resolution: resolution.presence)
20
+ end
21
+
22
+ def reopen
23
+ update!(resolved_at: nil, resolution: nil)
24
+ end
25
+
26
+ def resolved? = resolved_at.present?
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ <%# The composer. Hidden until the controller captures a click, which fills the selector
2
+ and snippet fields; the author only ever types the body. A plain Rails form: Turbo
3
+ submits it and the redirect repaints the frame. %>
4
+ <%= form_with model: note, class: "redpen-composer", data: { redpen_target: "composer" } do |form| %>
5
+ <%= form.hidden_field :path %>
6
+ <%= form.hidden_field :selector, data: { redpen_target: "selector" } %>
7
+ <%= form.hidden_field :snippet, data: { redpen_target: "snippet" } %>
8
+ <div class="redpen-composer__target" data-redpen-target="composerTarget"></div>
9
+ <%= form.text_area :body, required: true, rows: 3, placeholder: "What should change here?",
10
+ data: { redpen_target: "body", action: "keydown.meta+enter->redpen#submit keydown.ctrl+enter->redpen#submit" } %>
11
+ <div class="redpen-note__actions">
12
+ <button type="button" class="redpen-btn redpen-btn--quiet" data-action="redpen#close">Cancel</button>
13
+ <%= form.submit "Save", class: "redpen-btn redpen-btn--primary" %>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%# One note: the popover a pin opens, and the row an orphan shows as. The selector and
2
+ snippet on the element are what the controller pins by: the DOM attribute is the state. %>
3
+ <li id="<%= dom_id note %>" class="redpen-note <%= "redpen-note--resolved" if note.resolved? %>"
4
+ data-redpen-target="note" data-note-selector="<%= note.selector %>" data-note-snippet="<%= note.snippet %>"
5
+ data-action="click->redpen#stop">
6
+ <div class="redpen-note__head">
7
+ <span class="redpen-note__number"></span>
8
+ <span class="redpen-note__snippet" title="<%= note.snippet %>"><%= note.snippet.presence || note.selector %></span>
9
+ </div>
10
+ <p class="redpen-note__body"><%= note.body %></p>
11
+ <% if note.resolved? %>
12
+ <p class="redpen-note__resolution">
13
+ <span class="redpen-note__label">Resolved <%= time_ago_in_words(note.resolved_at) %> ago</span>
14
+ <% if note.resolution.present? %><span><%= note.resolution %></span><% end %>
15
+ </p>
16
+ <% end %>
17
+ <div class="redpen-note__meta"><%= note.created_at.strftime("%-d %b %Y, %H:%M") %></div>
18
+ <div class="redpen-note__actions">
19
+ <%= button_to "Delete", note_path(note), method: :delete, class: "redpen-btn redpen-btn--quiet", form: { data: { turbo_confirm: "Delete this note?" } } %>
20
+ <% if note.resolved? %>
21
+ <%= button_to "Reopen", note_resolution_path(note), method: :delete, class: "redpen-btn" %>
22
+ <% else %>
23
+ <%= button_to "Resolve", note_resolution_path(note), method: :post, class: "redpen-btn redpen-btn--primary" %>
24
+ <% end %>
25
+ </div>
26
+ </li>
@@ -0,0 +1,9 @@
1
+ <%# The red pen. The frame pulls the page's notes after the page paints, so the page
2
+ itself carries only this: a stylesheet link, a module tag, an empty frame. The
3
+ controller lives on the frame, which survives every re-render of its contents. %>
4
+ <%= stylesheet_link_tag "redpen" %>
5
+ <%= javascript_import_module_tag "redpen" %>
6
+ <turbo-frame id="redpen_notes" src="<%= redpen.notes_path(path: path) %>" class="redpen"
7
+ data-controller="redpen"
8
+ data-action="turbo:frame-load->redpen#pin resize@window->redpen#pin keydown@document->redpen#keydown">
9
+ </turbo-frame>
@@ -0,0 +1,6 @@
1
+ <%# Spliced before </body> of a verbatim document. It has no head of ours, so the rail
2
+ brings the import map along, and Turbo Drive is switched off: the document's links
3
+ and scripts were written for a plain browser, and the frame works without Drive. %>
4
+ <%= javascript_inline_importmap_tag %>
5
+ <script type="module">import { Turbo } from "@hotwired/turbo-rails"; Turbo.session.drive = false</script>
6
+ <%= render "redpen/notes/rail", path: path %>
@@ -0,0 +1,22 @@
1
+ <turbo-frame id="redpen_notes">
2
+ <button type="button" class="redpen-toggle" data-action="redpen#toggle" data-redpen-target="toggle">
3
+ <span class="redpen-toggle__dot"></span>
4
+ <span data-redpen-target="toggleLabel">Red pen</span>
5
+ </button>
6
+
7
+ <div class="redpen-highlight" data-redpen-target="highlight" aria-hidden="true"></div>
8
+
9
+ <%# Document-coordinate layer: pins, open popovers and the composer are positioned in here. %>
10
+ <div class="redpen-layer" data-redpen-target="layer">
11
+ <ol class="redpen-notes" data-redpen-target="notes">
12
+ <%= render partial: "redpen/notes/note", collection: @notes %>
13
+ </ol>
14
+ <%= render "redpen/notes/form", note: Redpen::Note.new(path: @path) %>
15
+ </div>
16
+
17
+ <%# Notes whose element is gone from the page. Filled by the controller at pin time. %>
18
+ <aside class="redpen-orphans" data-redpen-target="orphans">
19
+ <h3 class="redpen-orphans__title">Element gone</h3>
20
+ <ol class="redpen-notes"></ol>
21
+ </aside>
22
+ </turbo-frame>
@@ -0,0 +1,2 @@
1
+ # The rail imports the entry; no page preloads it, so a reader's <head> never mentions it.
2
+ pin "redpen", to: "redpen.js", preload: false
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ Redpen::Engine.routes.draw do
2
+ # Notes on a page, and resolving one: create resolves, destroy reopens. No verbs.
3
+ resources :notes, only: %i[ index create destroy ] do
4
+ resource :resolution, only: %i[ create destroy ]
5
+ end
6
+ end
@@ -0,0 +1,17 @@
1
+ class CreateRedpenNotes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :redpen_notes do |t|
4
+ # Attribution only: who left the note. Polymorphic so any host user class fits,
5
+ # and no foreign key so the engine never assumes a table name.
6
+ t.references :author, polymorphic: true, null: false
7
+ # The page is its path; the element is a CSS selector into it.
8
+ t.string :path, null: false, index: true
9
+ t.string :selector, null: false
10
+ t.string :snippet
11
+ t.text :body, null: false
12
+ t.datetime :resolved_at
13
+ t.text :resolution
14
+ t.timestamps
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,34 @@
1
+ module Redpen
2
+ module Generators
3
+ # bin/rails generate redpen:install
4
+ #
5
+ # Migration, initializer, mount line. Idempotent: run it twice and nothing doubles.
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def copy_migrations
10
+ rake "redpen:install:migrations"
11
+ end
12
+
13
+ def create_initializer
14
+ template "initializer.rb", "config/initializers/redpen.rb"
15
+ end
16
+
17
+ def mount_engine
18
+ route 'mount Redpen::Engine, at: "/redpen"' unless File.read("config/routes.rb").include?("Redpen::Engine")
19
+ end
20
+
21
+ def show_next_steps
22
+ say <<~MSG
23
+
24
+ Red pen is installed. Three things left:
25
+
26
+ 1. bin/rails db:migrate
27
+ 2. Open config/initializers/redpen.rb and say who holds the pen.
28
+ 3. Put <%= redpen_rail if signed_in? %> as the last line of <body> in your layout.
29
+
30
+ MSG
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,12 @@
1
+ # Who is holding the pen. Evaluated inside the engine's controller, so anything a
2
+ # controller can see works here: Current.user, current_user (Devise), a session lookup.
3
+ # Nil means nobody is signed in, and the pen answers 403.
4
+ Redpen.author = -> { Current.user }
5
+
6
+ # May this author pen this path? Called with the page path and the author. Default: any
7
+ # signed-in author, on any path. Narrow it to whoever owns the page:
8
+ #
9
+ # Redpen.annotatable = ->(path, author) { Community.at(path)&.administered_by?(author) }
10
+
11
+ # The engine's controllers inherit from this, so your authentication runs first.
12
+ # Redpen.parent_controller = "ApplicationController"
@@ -0,0 +1,15 @@
1
+ module Redpen
2
+ module Generators
3
+ # bin/rails generate redpen:views
4
+ #
5
+ # Copies the pen's templates into app/views/redpen/notes, where they take precedence
6
+ # over the engine's. For colours and fonts, override the --rp-* variables instead.
7
+ class ViewsGenerator < Rails::Generators::Base
8
+ source_root Redpen::Engine.root.join("app/views")
9
+
10
+ def copy_views
11
+ directory "redpen/notes", "app/views/redpen/notes"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,33 @@
1
+ module Redpen
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Redpen
4
+
5
+ # One prebuilt module and one stylesheet. Sprockets needs telling; Propshaft
6
+ # already serves everything on its paths.
7
+ initializer "redpen.assets" do |app|
8
+ if app.config.respond_to?(:assets)
9
+ app.config.assets.precompile += %w[ redpen.js redpen.css ]
10
+ end
11
+ end
12
+
13
+ # `pin "redpen"` lands in the host's import map without the host editing anything.
14
+ initializer "redpen.importmap", before: "importmap" do |app|
15
+ if app.config.respond_to?(:importmap)
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
+
21
+ # The host's own route helpers keep working inside the engine's controllers.
22
+ initializer "redpen.host_route_helpers" do |app|
23
+ ActiveSupport.on_load(:after_routes_loaded) do
24
+ Redpen::HostRouteHelpers.define_from(app.routes, Redpen::Engine.routes)
25
+ end
26
+ end
27
+
28
+ # `redpen_rail` and `redpen_inject` in every host view and controller.
29
+ initializer "redpen.helpers" do
30
+ ActiveSupport.on_load(:action_controller_base) { helper Redpen::RailHelper }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ module Redpen
2
+ # Route helpers of the host app that the engine doesn't define itself, routed through main_app.
3
+ #
4
+ # The engine's controllers inherit from the host's (see Redpen.parent_controller), so host
5
+ # code such as a before_action redirecting to new_session_path runs inside the engine, where
6
+ # route helpers resolve against the engine's routes and raise UrlGenerationError. For every
7
+ # helper the host has and the engine doesn't, this defines a method delegating to the host's
8
+ # routes, so that code keeps working unchanged. The pattern is Mission Control Jobs' (MIT,
9
+ # 37signals).
10
+ #
11
+ # Helpers both define, like root_path, keep resolving to the engine's; use main_app.root_path.
12
+ module HostRouteHelpers
13
+ class << self
14
+ def define_from(host_routes, engine_routes)
15
+ undefine_all
16
+
17
+ (host_routes.named_routes.helper_names - engine_routes.named_routes.helper_names).each do |name|
18
+ define_method(name) { |*args| main_app.public_send(name, *args) }
19
+ defined_helpers << name
20
+ end
21
+ end
22
+
23
+ private
24
+ def undefine_all
25
+ defined_helpers.each { |name| remove_method(name) }
26
+ defined_helpers.clear
27
+ end
28
+
29
+ def defined_helpers
30
+ @defined_helpers ||= []
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,3 @@
1
+ module Redpen
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1 @@
1
+ require "redpen"
data/lib/redpen.rb ADDED
@@ -0,0 +1,29 @@
1
+ require "importmap-rails"
2
+ require "turbo-rails"
3
+ require "stimulus-rails"
4
+
5
+ require "redpen/version"
6
+ require "redpen/host_route_helpers"
7
+ require "redpen/engine"
8
+
9
+ # The red pen asks the host app two questions and nothing else. Both lambdas run inside
10
+ # the engine's controller, so anything a controller can see works in them: Current.user,
11
+ # Devise's current_user, a session lookup.
12
+ module Redpen
13
+ # Who is holding the pen. Its answer becomes every note's author and is handed to
14
+ # `annotatable`. Nil means nobody is signed in, and the engine answers 403.
15
+ #
16
+ # Redpen.author = -> { Current.user }
17
+ # Redpen.author = -> { current_user } # Devise
18
+ mattr_accessor :author, default: -> { nil }
19
+
20
+ # May this author pen this path? Called with the page path and the author for every
21
+ # request that reads or writes notes on that path. Default: any signed-in author, anywhere.
22
+ #
23
+ # Redpen.annotatable = ->(path, author) { Community.at(path)&.administered_by?(author) }
24
+ mattr_accessor :annotatable, default: ->(path, author) { true }
25
+
26
+ # The engine's controllers inherit from this, so the host's own before_actions
27
+ # (authentication, Current.session, locale) run before the pen looks at a request.
28
+ mattr_accessor :parent_controller, default: "ApplicationController"
29
+ end
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redpen-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nityesh Agarwal
8
+ - Luo Ji
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '8.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '8.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: importmap-rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: turbo-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: stimulus-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '1.3'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '1.3'
69
+ description: 'A Rails engine that lets a signed-in person pin notes to elements of
70
+ any page: where on the page, what was there, what should change. Notes are read
71
+ back by the host app (or its agent), resolved with one line, and the line shows
72
+ up on the page.'
73
+ email:
74
+ - nityeshagarwal@gmail.com
75
+ executables: []
76
+ extensions: []
77
+ extra_rdoc_files: []
78
+ files:
79
+ - CHANGELOG.md
80
+ - MIT-LICENSE
81
+ - README.md
82
+ - Rakefile
83
+ - app/assets/javascripts/redpen.js
84
+ - app/assets/stylesheets/redpen.css
85
+ - app/controllers/redpen/application_controller.rb
86
+ - app/controllers/redpen/notes_controller.rb
87
+ - app/controllers/redpen/resolutions_controller.rb
88
+ - app/helpers/redpen/rail_helper.rb
89
+ - app/models/redpen/application_record.rb
90
+ - app/models/redpen/current.rb
91
+ - app/models/redpen/note.rb
92
+ - app/views/redpen/notes/_form.html.erb
93
+ - app/views/redpen/notes/_note.html.erb
94
+ - app/views/redpen/notes/_rail.html.erb
95
+ - app/views/redpen/notes/_standalone.html.erb
96
+ - app/views/redpen/notes/index.html.erb
97
+ - config/importmap.rb
98
+ - config/routes.rb
99
+ - db/migrate/20260915000001_create_redpen_notes.rb
100
+ - lib/generators/redpen/install/install_generator.rb
101
+ - lib/generators/redpen/install/templates/initializer.rb
102
+ - lib/generators/redpen/views/views_generator.rb
103
+ - lib/redpen-rails.rb
104
+ - lib/redpen.rb
105
+ - lib/redpen/engine.rb
106
+ - lib/redpen/host_route_helpers.rb
107
+ - lib/redpen/version.rb
108
+ homepage: https://github.com/nityeshaga/redpen
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ homepage_uri: https://github.com/nityeshaga/redpen
113
+ source_code_uri: https://github.com/nityeshaga/redpen
114
+ changelog_uri: https://github.com/nityeshaga/redpen/blob/main/CHANGELOG.md
115
+ rdoc_options: []
116
+ require_paths:
117
+ - lib
118
+ required_ruby_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '3.2'
123
+ required_rubygems_version: !ruby/object:Gem::Requirement
124
+ requirements:
125
+ - - ">="
126
+ - !ruby/object:Gem::Version
127
+ version: '0'
128
+ requirements: []
129
+ rubygems_version: 3.6.9
130
+ specification_version: 4
131
+ summary: Click any element of a page, leave a note. The feedback layer for AI-made
132
+ pages.
133
+ test_files: []