tensaku 0.0.1

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: 19a05003b74ad6d7a4e18d0bf6c838974e619cbb4e5319172b574b363b0cd16b
4
+ data.tar.gz: 90fc49efb273155c0a2a6da66586ebce56a0fa0bdf9ca2fbd441f6133d151d70
5
+ SHA512:
6
+ metadata.gz: 0c51109fc72f7c5c0b50fb90082780a5eb9c4afd30caab6af6c09a6ba7824cb41ea5ca56707b2d4d8e9b5d0dd83fdbd6f10b33e662bf0cd894e600eb07d9a70f
7
+ data.tar.gz: 80aa78b84cf8a88415886c6d2ef245aa900446a36dc5effe824905938bd081772b1eeacd3d1f762fd08b5092e51d44ea8f5cd0a0fdc1f7fd8600cc219a24f204
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Herb source stamps on every ERB element in development, through ReActionView's ERB interception and
6
+ Herb's `SourceAttributionVisitor`. Production keeps Erubi.
7
+ - Overlay with a corner button and Alt+Shift+T: hover to see sources, copy references with the
8
+ partial chain, Stimulus controller and Turbo Frame.
9
+ - Notes on elements, text selections and areas, stored in `tmp/tensaku/notes/`, with pins and a notes
10
+ panel. Agents read them as files, through the MCP endpoint, from the clipboard or with the Claude
11
+ Code skill.
12
+ - In-place editing of template text and attributes, translations (with other locales) and Markdown
13
+ blocks from producers, written back as byte splices with a stale-file check and one-step undo.
14
+ - Page update after a save: same-source nodes right away, then refetch and idiomorph.
15
+ - `bin/rails generate tensaku:install`.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2026 David Afonso
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,196 @@
1
+ # Tensaku
2
+
3
+ Tensaku (添削, correcting a draft) lets you fix a running Rails app from the page itself, in
4
+ development. Click a piece of text and edit it in place, and Tensaku writes the change back to the ERB
5
+ template, the locale YAML or the Markdown source. Or leave a note on an element or an area of the page,
6
+ and your coding agent reads it with the exact file and line attached.
7
+
8
+ Tensaku is built on [Herb](https://github.com/marcoroth/herb) through
9
+ [ReActionView](https://github.com/marcoroth/reactionview), and only runs in development. Production
10
+ doesn't change: it keeps rendering with Erubi and never loads Tensaku, ReActionView or Herb.
11
+
12
+ Status: 0.0.1, unreleased. Requires Ruby 4.0+ and Rails 8.1+.
13
+
14
+ ## Install
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ group :development do
19
+ gem "tensaku"
20
+ end
21
+ ```
22
+
23
+ ```sh
24
+ bundle install
25
+ bin/rails generate tensaku:install
26
+ ```
27
+
28
+ The generator checks the Gemfile group, writes `config/initializers/reactionview.rb`, installs the
29
+ Claude Code skill in `.claude/skills/tensaku/`, adds the MCP endpoint to `.mcp.json`, and makes sure
30
+ `tmp/` is ignored. It prints what it changed. No JavaScript setup is needed: the middleware injects one
31
+ prebuilt script, which works with importmap, jsbundling or no JavaScript at all.
32
+
33
+ ## Use
34
+
35
+ Start the server and open a page. A small round button sits in the bottom right corner (drag it
36
+ anywhere). Click it for the toolbar, or press **Alt+Shift+T** to go straight to select mode.
37
+
38
+ - **Select**: hover to see where an element comes from (`h1 app/views/pages/about.html.erb:1`), click
39
+ it for a menu:
40
+ - **Copy reference**: `GET /about → app/views/pages/_hero.html.erb:4:3`, the text, the partial
41
+ chain, the Stimulus controller, the Turbo Frame and the translation key.
42
+ - **Add note**: write what should change. Select some text first to note just that text.
43
+ - **Edit text** / **Edit attributes**.
44
+ - **Drag** in select mode to note an area. The note collects every stamped element inside it.
45
+ - **Alt/Option-click** any text to edit it right away, in any mode.
46
+ - **Edit**: outlines every editable text on the page. A normal click edits.
47
+ - **Notes**: the notes on this page, copy them as Markdown, resolve, delete or answer questions.
48
+
49
+ The editor shows the source, never the rendered text. In `Hello <%= user.name %>!` you edit `Hello ` and
50
+ `!`; the Ruby shows as a locked chip. Translations show `%{name}` interpolations as chips, and
51
+ "Other locales" lets you update the same key in every locale at once. Enter or leaving the editor
52
+ saves, Shift+Enter adds a line break in Markdown, Escape cancels. After a save, a toast offers Undo.
53
+
54
+ ## What can be edited
55
+
56
+ | Source | How Tensaku finds it | Written to |
57
+ |---|---|---|
58
+ | Static text in ERB views, partials, layouts, ViewComponent sidecar templates, mailer templates | `data-herb-source="path:line:col"`, stamped by Herb's `SourceAttributionVisitor` | The template, only the bytes of that text |
59
+ | `alt`, `title`, `placeholder`, `aria-label`, button `value` | The same stamp | The attribute value, keeping its quotes |
60
+ | `t()` translations, in text and attributes | An invisible marker the I18n backend adds in development | The locale YAML, only the value's bytes |
61
+ | Markdown blocks (`p`, headings, `li`, `blockquote`) | `data-tensaku-md="path:line:col-line:col"` from a producer such as Kiln's content layer | The Markdown file, inside the block's range |
62
+
63
+ Everything else on the page (text from Ruby data like `@post.title`, output of `form_with` or custom
64
+ helpers, DOM built by JavaScript) can still get notes, placed through the nearest stamped element.
65
+
66
+ Every write is a splice: Tensaku re-reads the file, checks that the bytes it's about to replace are
67
+ still what the page was rendered from, and replaces exactly those bytes through a temp file and a
68
+ rename. YAML is never loaded and dumped, so comments, ordering and quoting stay. If the file changed
69
+ since the page rendered, the edit is refused and nothing is merged or guessed.
70
+
71
+ After a save, Tensaku clears Action View's template cache (and ViewComponent's, and reloads I18n),
72
+ updates every copy of the text on the page right away, then fetches the page again and morphs it in
73
+ with [idiomorph](https://github.com/bigskysoftware/idiomorph). Stimulus controllers stay connected,
74
+ no Turbo visit fires, and elements marked `data-turbo-permanent` or `data-tensaku-permanent` are left
75
+ alone. On a page reached by a POST (a form that rendered errors) Tensaku updates the text it knows and
76
+ asks you to reload.
77
+
78
+ Elements rendered in a loop share one stamp: the editor says "this edits all 3 copies".
79
+
80
+ ## Notes for agents
81
+
82
+ Notes are JSON files in `tmp/tensaku/notes/`, one per note: local, ignored by git, cleared by
83
+ `bin/rails tmp:clear`. Resolved notes are deleted after three days.
84
+
85
+ ```json
86
+ {
87
+ "id": "a7f3",
88
+ "url": "/about",
89
+ "comment": "tighten this, and the heading is wrong",
90
+ "targets": [
91
+ { "kind": "element", "file": "app/views/pages/_hero.html.erb", "range": "4:3-4:40", "tag": "h1", "text": "About me" }
92
+ ],
93
+ "status": "open",
94
+ "reply": null
95
+ }
96
+ ```
97
+
98
+ An agent gets them four ways:
99
+
100
+ - **Files**: read `tmp/tensaku/notes/*.json`.
101
+ - **MCP**: `http://localhost:3000/__tensaku/mcp` (Streamable HTTP). Tools: `list_notes`, `get_note`,
102
+ `resolve_note`, `ask_question`, `watch_notes` (waits for new notes) and `current_selection`. The
103
+ generator adds it to `.mcp.json`.
104
+ - **Clipboard**: copy one note or all open notes as Markdown from the page, for any chat.
105
+ - **Claude Code skill**: `.claude/skills/tensaku/SKILL.md` explains how to find, act on and resolve notes.
106
+
107
+ A note is `open`, `question` (the agent asked something back; the question shows on the page) or
108
+ `resolved` (with a reply; the pin turns green so you can check the change, then dismiss it).
109
+
110
+ ## Producers and attributes
111
+
112
+ The overlay only reads attributes, so it doesn't care what rendered the page.
113
+
114
+ | Attribute | Set by | Meaning |
115
+ |---|---|---|
116
+ | `data-herb-source="path:line:col"` | Herb | Element's open tag position (1-based) |
117
+ | Marker inside translated strings | Tensaku's I18n backend | Locale and key. Stripped by the overlay. |
118
+ | `data-tensaku-group` | The app | Widen the highlight and the edit target to this element |
119
+ | `data-tensaku-md="path:line:col-line:col"` | A Markdown producer, development only | Block's Markdown source range (byte columns, end inclusive) |
120
+ | `data-tensaku-permanent` | The app | The morph after a save leaves this element alone (client-rendered islands) |
121
+ | `<meta name="tensaku" data-tensaku-request="GET">` | Tensaku | Request method, so the overlay knows whether it can fetch the page again |
122
+
123
+ [Kiln](https://github.com/davafons/kiln)'s content layer emits `data-tensaku-md` in development when
124
+ Tensaku is loaded, and Kiln's content directory is writable automatically. Kiln builds fail if any
125
+ `data-herb-*` or `data-tensaku-*` attribute reaches the output.
126
+
127
+ ## Configuration
128
+
129
+ ```ruby
130
+ # config/initializers/tensaku.rb
131
+ if defined?(Tensaku)
132
+ Tensaku.configure do |config|
133
+ config.writable_paths << "content" # default: app/views app/components config/locales
134
+ config.shortcut = "alt+shift+KeyT" # modifiers and a KeyboardEvent#code
135
+ config.prune_after = 7 * 24 * 60 * 60 # seconds before resolved notes are deleted
136
+ end
137
+ end
138
+ ```
139
+
140
+ Other gems can call `Tensaku.register_writable_path("their/dir")`.
141
+
142
+ ## Safety
143
+
144
+ - Development only. The railtie, middleware and I18n markers check `Rails.env.development?`, so nothing
145
+ loads in test or production even if the gem is in the wrong Gemfile group.
146
+ - Every endpoint answers loopback addresses only. Everything the overlay calls needs a per-boot token
147
+ embedded in the page. The MCP endpoint can't receive that token from an MCP client, so it also
148
+ refuses requests with a foreign `Origin` or a non-local `Host` (browsers can't reach it from another
149
+ site). It reads and updates notes; it never writes source files.
150
+ - Writes are limited to the writable paths, checked after resolving symlinks.
151
+
152
+ ## Limits
153
+
154
+ - Text from Ruby data, rich text (bold, links, splitting paragraphs), Phlex views and inline
155
+ ViewComponent templates (`erb_template`) are out of scope. They get notes.
156
+ - Pluralized translations (`count:`) aren't marked, so they aren't edited in the page.
157
+ - Translation markers are part of the string while Ruby handles it, in development. A comparison like
158
+ `t(".title") == "Welcome"` inside a controller action or view is false in development.
159
+ - Fragment caching in development (`bin/rails dev:cache`) keeps showing the old text from cached
160
+ fragments. Tensaku warns when it's on.
161
+ - One person editing at a time. No undo history beyond one step: version control is the history.
162
+ - Herb's dev server patches template changes itself when it runs (ReActionView's `slots` option), and
163
+ Tensaku then skips its own refetch for template edits.
164
+
165
+ ## Development
166
+
167
+ ```sh
168
+ bundle install
169
+ bun install # only to change the overlay
170
+ bin/test # unit and browser tests (Cuprite, needs Chrome)
171
+ bin/lint # RuboCop, house rules, TypeScript, compiled overlay up to date
172
+ bun run build # app/javascript/tensaku/*.ts -> app/assets/tensaku.js (committed)
173
+ ```
174
+
175
+ The documentation site is a Rails app in `docs/`, rendered to static files by Kiln and styled with
176
+ lostcoords-ui. Tensaku runs on it in development, so it's also where Tensaku is tried on a real site:
177
+
178
+ ```sh
179
+ cd docs
180
+ bundle install
181
+ bin/rails tailwindcss:build
182
+ bin/rails server # edit the docs from the page
183
+ RAILS_ENV=production bin/rails kiln:build # static site in docs/dist
184
+ ```
185
+
186
+ With [Parcela](https://parcela.run), `parcela up --detach` runs the docs server and the Tailwind watcher at
187
+ https://tensaku.localhost.
188
+
189
+ CI builds the docs with `kiln:build` on every push.
190
+
191
+ The test suite runs `test/dummy` in the development environment, since that's the only place Tensaku
192
+ switches on. Browser tests write to the dummy app's files and restore them afterwards.
193
+
194
+ ## License
195
+
196
+ MIT
@@ -0,0 +1,85 @@
1
+ (()=>{function meta(){let tag=document.querySelector('meta[name="tensaku"]');return{token:tag?.dataset.tensakuToken??"",root:tag?.dataset.tensakuRoot??"/__tensaku",method:tag?.dataset.tensakuRequest??"GET",shortcut:tag?.dataset.tensakuShortcut??"alt+shift+KeyT"}}class ApiError extends Error{status;stale;constructor(message,status,stale){super(message);this.status=status;this.stale=stale}}async function api(method,path,body){let{token,root}=meta(),response=await fetch(root+path,{method,headers:{"Content-Type":"application/json","X-Tensaku-Token":token,Accept:"application/json"},body:body===void 0?void 0:JSON.stringify(body),credentials:"same-origin"}),data=(response.headers.get("content-type")??"").includes("json")?await response.json():await response.text();if(!response.ok){let message=typeof data==="object"&&data?.error?data.error:`Tensaku request failed (${response.status})`;throw new ApiError(message,response.status,Boolean(data?.stale))}return data}var query=(params)=>"?"+new URLSearchParams(Object.entries(params).filter((entry)=>entry[1]!==void 0)).toString();var DIGITS=["​","‌","‍","⁠"],PATTERN=/⁣[​‌‍⁠]+⁣/g,nodeKeys=new WeakMap,attributeKeys=new WeakMap;function decode(marker){let digits=[...marker.slice(1,-1)].map((char)=>DIGITS.indexOf(char));if(digits.length%4!==0||digits.includes(-1))return null;let bytes=new Uint8Array(digits.length/4);for(let i=0;i<bytes.length;i++){let[a,b,c,d]=digits.slice(i*4,i*4+4);bytes[i]=a<<6|b<<4|c<<2|d}let text=new TextDecoder().decode(bytes),colon=text.indexOf(":");return colon>0?{locale:text.slice(0,colon),key:text.slice(colon+1)}:null}function extract(text){let keys=[];return{text:text.replace(PATTERN,(marker)=>{let key=decode(marker);if(key)keys.push(key);return""}),keys}}function stripText(node){if(!node.data.includes("⁣"))return;let{text,keys}=extract(node.data);if(node.data=text,keys.length)nodeKeys.set(node,[...nodeKeys.get(node)??[],...keys])}function stripAttributes(element){for(let attribute of Array.from(element.attributes)){if(!attribute.value.includes("⁣"))continue;let{text,keys}=extract(attribute.value);if(element.setAttribute(attribute.name,text),keys.length){let map=attributeKeys.get(element)??new Map;map.set(attribute.name,keys[keys.length-1]),attributeKeys.set(element,map)}}if(element instanceof HTMLTextAreaElement&&element.value.includes("⁣"))element.value=extract(element.value).text}function strip(root){if(root.nodeType===Node.TEXT_NODE)return stripText(root);if(!(root instanceof Element||root instanceof Document||root instanceof DocumentFragment))return;if(root instanceof Element)stripAttributes(root);let walker=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT);for(let node=walker.nextNode();node;node=walker.nextNode())if(node.nodeType===Node.TEXT_NODE)stripText(node);else stripAttributes(node)}function watch(){if(strip(document.documentElement),document.title.includes("⁣"))document.title=extract(document.title).text;new MutationObserver((records)=>{for(let record of records)if(record.type==="characterData")stripText(record.target);else if(record.type==="attributes")stripAttributes(record.target);else record.addedNodes.forEach((node)=>strip(node))}).observe(document.documentElement,{subtree:!0,childList:!0,characterData:!0,attributes:!0})}function textKeys(element){let found=[],walker=document.createTreeWalker(element,NodeFilter.SHOW_TEXT);for(let node=walker.nextNode();node;node=walker.nextNode()){let keys=nodeKeys.get(node);if(!keys)continue;let owner=node.parentElement?.closest("[data-herb-source], [data-tensaku-md]");if(owner&&owner!==element&&element.contains(owner))continue;keys.forEach((key)=>found.push({node,key}))}return found}function attributeKey(element,name){return attributeKeys.get(element)?.get(name)}function attributeKeyNames(element){return Array.from(attributeKeys.get(element)?.keys()??[])}function nodesForKey(key){let nodes=[],walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT);for(let node=walker.nextNode();node;node=walker.nextNode())if(nodeKeys.get(node)?.some((k)=>k.key===key.key&&k.locale===key.locale))nodes.push(node);return nodes}function elementsWithAttributeKey(key){let found=[];return document.querySelectorAll("*").forEach((element)=>{attributeKeys.get(element)?.forEach((k,name)=>{if(k.key===key.key&&k.locale===key.locale)found.push({element,name})})}),found}var STAMP="data-herb-source",MARKDOWN="data-tensaku-md",SOURCE_SELECTOR=`[${STAMP}], [${MARKDOWN}]`;function parseStamp(value){let match=value?.match(/^(.+):(\d+):(\d+)$/);return match?{file:match[1],line:Number(match[2]),column:Number(match[3]),value}:null}function isUi(node){return node instanceof Element&&Boolean(node.closest("[data-tensaku-ui]"))}function sourceElement(node){let start=node instanceof Element?node:node?.parentElement;if(!start||isUi(start))return null;let group=start.closest("[data-tensaku-group]");if(group)return group;return start.closest(SOURCE_SELECTOR)}function stampOf(element){return parseStamp(element.getAttribute(STAMP))}function nearestStamp(element){return parseStamp(element.closest(`[${STAMP}]`)?.getAttribute(STAMP))}function copiesOf(element){let value=element.getAttribute(STAMP);return value?document.querySelectorAll(`[${STAMP}="${CSS.escape(value)}"]`).length:1}function partialChain(element){let files=[];for(let node=element;node;node=node.parentElement){let stamp=parseStamp(node.getAttribute(STAMP));if(stamp&&files[files.length-1]!==stamp.file)files.push(stamp.file)}return files.filter((file,index)=>files.indexOf(file)===index)}function contextOf(element){return{partials:partialChain(element),controller:element.closest("[data-controller]")?.getAttribute("data-controller")??void 0,frame:element.closest("turbo-frame")?.id??void 0}}function tagLabel(element){let tag=element.tagName.toLowerCase();if(element.id)return`${tag}#${element.id}`;let first=Array.from(element.classList).find((name)=>!name.startsWith("tensaku"));return first?`${tag}.${first}`:tag}function visibleText(element,limit=120){let text=(element instanceof HTMLElement?element.innerText:element.textContent??"").replace(/\s+/g," ").trim();return text.length>limit?text.slice(0,limit-1)+"…":text}function hasOwnText(element){for(let node of Array.from(element.childNodes))if(node.nodeType===Node.TEXT_NODE&&node.textContent.trim())return!0;return!1}function markdownTag(element){return element.tagName.toLowerCase()}var MARKDOWN_EDITABLE=new Set(["p","h1","h2","h3","h4","h5","h6","li","blockquote"]),ATTRIBUTES=["alt","title","placeholder","aria-label","value"];function editKind(element){if(translationOf(element))return"translation";if(element.hasAttribute(MARKDOWN)&&MARKDOWN_EDITABLE.has(markdownTag(element)))return"markdown";if(element.hasAttribute(STAMP)&&hasOwnText(element))return"template";if(editableAttributes(element).length)return"attributes";return null}function translationOf(element){let keys=textKeys(element);if(!keys.length)return null;let own=keys[0].node.data.trim(),all=(element.textContent??"").replace(/\s+/g," ").trim();return own&&all.length<=own.length*2+20?keys[0].key:null}function editableAttributes(element){let names=ATTRIBUTES.filter((name)=>element.hasAttribute(name)).filter((name)=>name!=="value"||element.matches("button, input[type=submit], input[type=button], input[type=reset]")),fromStamp=element.hasAttribute(STAMP)?names:[];return Array.from(new Set([...fromStamp,...attributeKeyNames(element).filter((name)=>ATTRIBUTES.includes(name))]))}function documentRect(element){let rect=element.getBoundingClientRect();return new DOMRect(rect.left+scrollX,rect.top+scrollY,rect.width,rect.height)}function contentRect(element){let outer=documentRect(element),range=document.createRange();range.selectNodeContents(element);let inner=range.getBoundingClientRect();if(!inner.width||!inner.height)return outer;let left=Math.max(outer.left,inner.left+scrollX),top=Math.max(outer.top,inner.top+scrollY);return new DOMRect(left,top,Math.min(outer.right,inner.right+scrollX)-left,Math.min(outer.bottom,inner.bottom+scrollY)-top)}function utf8Length(text){return new TextEncoder().encode(text).length}var Idiomorph=function(){let noOp=()=>{},defaults={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:noOp,afterNodeAdded:noOp,beforeNodeMorphed:noOp,afterNodeMorphed:noOp,beforeNodeRemoved:noOp,afterNodeRemoved:noOp,beforeAttributeUpdated:noOp},head:{style:"merge",shouldPreserve:(elt)=>elt.getAttribute("im-preserve")==="true",shouldReAppend:(elt)=>elt.getAttribute("im-re-append")==="true",shouldRemove:noOp,afterHeadMorphed:noOp},restoreFocus:!0};function morph(oldNode,newContent,config={}){oldNode=normalizeElement(oldNode);let newNode=normalizeParent(newContent),ctx=createMorphContext(oldNode,newNode,config),morphedNodes=saveAndRestoreFocus(ctx,()=>{return withHeadBlocking(ctx,oldNode,newNode,(ctx2)=>{if(ctx2.morphStyle==="innerHTML")return morphChildren(ctx2,oldNode,newNode),Array.from(oldNode.childNodes);else return morphOuterHTML(ctx2,oldNode,newNode)})});return ctx.pantry.remove(),morphedNodes}function morphOuterHTML(ctx,oldNode,newNode){let oldParent=normalizeParent(oldNode);return morphChildren(ctx,oldParent,newNode,oldNode,oldNode.nextSibling),Array.from(oldParent.childNodes)}function saveAndRestoreFocus(ctx,fn){if(!ctx.config.restoreFocus)return fn();let activeElement=document.activeElement;if(!(activeElement instanceof HTMLInputElement||activeElement instanceof HTMLTextAreaElement))return fn();let{id:activeElementId,selectionStart,selectionEnd}=activeElement,results=fn();if(activeElementId&&activeElementId!==document.activeElement?.getAttribute("id"))activeElement=ctx.target.querySelector(`[id="${activeElementId}"]`),activeElement?.focus();if(activeElement&&!activeElement.selectionEnd&&selectionEnd)activeElement.setSelectionRange(selectionStart,selectionEnd);return results}let morphChildren=function(){function morphChildren2(ctx,oldParent,newParent,insertionPoint=null,endPoint=null){if(oldParent instanceof HTMLTemplateElement&&newParent instanceof HTMLTemplateElement)oldParent=oldParent.content,newParent=newParent.content;insertionPoint||=oldParent.firstChild;for(let newChild of newParent.childNodes){if(insertionPoint&&insertionPoint!=endPoint){let bestMatch=findBestMatch(ctx,newChild,insertionPoint,endPoint);if(bestMatch){if(bestMatch!==insertionPoint)removeNodesBetween(ctx,insertionPoint,bestMatch);morphNode(bestMatch,newChild,ctx),insertionPoint=bestMatch.nextSibling;continue}}if(newChild instanceof Element){let newChildId=newChild.getAttribute("id");if(ctx.persistentIds.has(newChildId)){let movedChild=moveBeforeById(oldParent,newChildId,insertionPoint,ctx);morphNode(movedChild,newChild,ctx),insertionPoint=movedChild.nextSibling;continue}}let insertedNode=createNode(oldParent,newChild,insertionPoint,ctx);if(insertedNode)insertionPoint=insertedNode.nextSibling}while(insertionPoint&&insertionPoint!=endPoint){let tempNode=insertionPoint;insertionPoint=insertionPoint.nextSibling,removeNode(ctx,tempNode)}}function createNode(oldParent,newChild,insertionPoint,ctx){if(ctx.callbacks.beforeNodeAdded(newChild)===!1)return null;if(ctx.idMap.has(newChild)){let newEmptyChild=document.createElement(newChild.tagName);return oldParent.insertBefore(newEmptyChild,insertionPoint),morphNode(newEmptyChild,newChild,ctx),ctx.callbacks.afterNodeAdded(newEmptyChild),newEmptyChild}else{let newClonedChild=document.importNode(newChild,!0);return oldParent.insertBefore(newClonedChild,insertionPoint),ctx.callbacks.afterNodeAdded(newClonedChild),newClonedChild}}let findBestMatch=function(){function findBestMatch2(ctx,node,startPoint,endPoint){let softMatch=null,nextSibling=node.nextSibling,siblingSoftMatchCount=0,cursor=startPoint;while(cursor&&cursor!=endPoint){if(isSoftMatch(cursor,node)){if(isIdSetMatch(ctx,cursor,node))return cursor;if(softMatch===null){if(!ctx.idMap.has(cursor))softMatch=cursor}}if(softMatch===null&&nextSibling&&isSoftMatch(cursor,nextSibling)){if(siblingSoftMatchCount++,nextSibling=nextSibling.nextSibling,siblingSoftMatchCount>=2)softMatch=void 0}if(ctx.activeElementAndParents.includes(cursor))break;cursor=cursor.nextSibling}return softMatch||null}function isIdSetMatch(ctx,oldNode,newNode){let oldSet=ctx.idMap.get(oldNode),newSet=ctx.idMap.get(newNode);if(!newSet||!oldSet)return!1;for(let id of oldSet)if(newSet.has(id))return!0;return!1}function isSoftMatch(oldNode,newNode){let oldElt=oldNode,newElt=newNode;return oldElt.nodeType===newElt.nodeType&&oldElt.tagName===newElt.tagName&&(!oldElt.getAttribute?.("id")||oldElt.getAttribute?.("id")===newElt.getAttribute?.("id"))}return findBestMatch2}();function removeNode(ctx,node){if(ctx.idMap.has(node))moveBefore(ctx.pantry,node,null);else{if(ctx.callbacks.beforeNodeRemoved(node)===!1)return;node.parentNode?.removeChild(node),ctx.callbacks.afterNodeRemoved(node)}}function removeNodesBetween(ctx,startInclusive,endExclusive){let cursor=startInclusive;while(cursor&&cursor!==endExclusive){let tempNode=cursor;cursor=cursor.nextSibling,removeNode(ctx,tempNode)}return cursor}function moveBeforeById(parentNode,id,after,ctx){let target=ctx.target.getAttribute?.("id")===id&&ctx.target||ctx.target.querySelector(`[id="${id}"]`)||ctx.pantry.querySelector(`[id="${id}"]`);return removeElementFromAncestorsIdMaps(target,ctx),moveBefore(parentNode,target,after),target}function removeElementFromAncestorsIdMaps(element,ctx){let id=element.getAttribute("id");while(element=element.parentNode){let idSet=ctx.idMap.get(element);if(idSet){if(idSet.delete(id),!idSet.size)ctx.idMap.delete(element)}}}function moveBefore(parentNode,element,after){if(parentNode.moveBefore)try{parentNode.moveBefore(element,after)}catch(e){parentNode.insertBefore(element,after)}else parentNode.insertBefore(element,after)}return morphChildren2}(),morphNode=function(){function morphNode2(oldNode,newContent,ctx){if(ctx.ignoreActive&&oldNode===document.activeElement)return null;if(ctx.callbacks.beforeNodeMorphed(oldNode,newContent)===!1)return oldNode;if(oldNode instanceof HTMLHeadElement&&ctx.head.ignore);else if(oldNode instanceof HTMLHeadElement&&ctx.head.style!=="morph")handleHeadElement(oldNode,newContent,ctx);else if(morphAttributes(oldNode,newContent,ctx),!ignoreValueOfActiveElement(oldNode,ctx))morphChildren(ctx,oldNode,newContent);return ctx.callbacks.afterNodeMorphed(oldNode,newContent),oldNode}function morphAttributes(oldNode,newNode,ctx){let type=newNode.nodeType;if(type===1){let oldElt=oldNode,newElt=newNode,oldAttributes=oldElt.attributes,newAttributes=newElt.attributes;for(let newAttribute of newAttributes){if(ignoreAttribute(newAttribute.name,oldElt,"update",ctx))continue;if(oldElt.getAttribute(newAttribute.name)!==newAttribute.value)oldElt.setAttribute(newAttribute.name,newAttribute.value)}for(let i=oldAttributes.length-1;0<=i;i--){let oldAttribute=oldAttributes[i];if(!oldAttribute)continue;if(!newElt.hasAttribute(oldAttribute.name)){if(ignoreAttribute(oldAttribute.name,oldElt,"remove",ctx))continue;oldElt.removeAttribute(oldAttribute.name)}}if(!ignoreValueOfActiveElement(oldElt,ctx))syncInputValue(oldElt,newElt,ctx)}if(type===8||type===3){if(oldNode.nodeValue!==newNode.nodeValue)oldNode.nodeValue=newNode.nodeValue}}function syncInputValue(oldElement,newElement,ctx){if(oldElement instanceof HTMLInputElement&&newElement instanceof HTMLInputElement&&newElement.type!=="file"){let newValue=newElement.value,oldValue=oldElement.value;if(syncBooleanAttribute(oldElement,newElement,"checked",ctx),syncBooleanAttribute(oldElement,newElement,"disabled",ctx),!newElement.hasAttribute("value")){if(!ignoreAttribute("value",oldElement,"remove",ctx))oldElement.value="",oldElement.removeAttribute("value")}else if(oldValue!==newValue){if(!ignoreAttribute("value",oldElement,"update",ctx))oldElement.setAttribute("value",newValue),oldElement.value=newValue}}else if(oldElement instanceof HTMLOptionElement&&newElement instanceof HTMLOptionElement)syncBooleanAttribute(oldElement,newElement,"selected",ctx);else if(oldElement instanceof HTMLTextAreaElement&&newElement instanceof HTMLTextAreaElement){let newValue=newElement.value,oldValue=oldElement.value;if(ignoreAttribute("value",oldElement,"update",ctx))return;if(newValue!==oldValue)oldElement.value=newValue;if(oldElement.firstChild&&oldElement.firstChild.nodeValue!==newValue)oldElement.firstChild.nodeValue=newValue}}function syncBooleanAttribute(oldElement,newElement,attributeName,ctx){let newLiveValue=newElement[attributeName],oldLiveValue=oldElement[attributeName];if(newLiveValue!==oldLiveValue){let ignoreUpdate=ignoreAttribute(attributeName,oldElement,"update",ctx);if(!ignoreUpdate)oldElement[attributeName]=newElement[attributeName];if(newLiveValue){if(!ignoreUpdate)oldElement.setAttribute(attributeName,"")}else if(!ignoreAttribute(attributeName,oldElement,"remove",ctx))oldElement.removeAttribute(attributeName)}}function ignoreAttribute(attr,element,updateType,ctx){if(attr==="value"&&ctx.ignoreActiveValue&&element===document.activeElement)return!0;return ctx.callbacks.beforeAttributeUpdated(attr,element,updateType)===!1}function ignoreValueOfActiveElement(possibleActiveElement,ctx){return!!ctx.ignoreActiveValue&&possibleActiveElement===document.activeElement&&possibleActiveElement!==document.body}return morphNode2}();function withHeadBlocking(ctx,oldNode,newNode,callback){if(ctx.head.block){let oldHead=oldNode.querySelector("head"),newHead=newNode.querySelector("head");if(oldHead&&newHead){let promises=handleHeadElement(oldHead,newHead,ctx);return Promise.all(promises).then(()=>{let newCtx=Object.assign(ctx,{head:{block:!1,ignore:!0}});return callback(newCtx)})}}return callback(ctx)}function handleHeadElement(oldHead,newHead,ctx){let added=[],removed=[],preserved=[],nodesToAppend=[],srcToNewHeadNodes=new Map;for(let newHeadChild of newHead.children)srcToNewHeadNodes.set(newHeadChild.outerHTML,newHeadChild);for(let currentHeadElt of oldHead.children){let inNewContent=srcToNewHeadNodes.has(currentHeadElt.outerHTML),isReAppended=ctx.head.shouldReAppend(currentHeadElt),isPreserved=ctx.head.shouldPreserve(currentHeadElt);if(inNewContent||isPreserved)if(isReAppended)removed.push(currentHeadElt);else srcToNewHeadNodes.delete(currentHeadElt.outerHTML),preserved.push(currentHeadElt);else if(ctx.head.style==="append"){if(isReAppended)removed.push(currentHeadElt),nodesToAppend.push(currentHeadElt)}else if(ctx.head.shouldRemove(currentHeadElt)!==!1)removed.push(currentHeadElt)}nodesToAppend.push(...srcToNewHeadNodes.values());let promises=[];for(let newNode of nodesToAppend){let newElt=document.createRange().createContextualFragment(newNode.outerHTML).firstChild;if(ctx.callbacks.beforeNodeAdded(newElt)!==!1){if("href"in newElt&&newElt.href||"src"in newElt&&newElt.src){let resolve,promise=new Promise(function(_resolve){resolve=_resolve});newElt.addEventListener("load",function(){resolve()}),promises.push(promise)}oldHead.appendChild(newElt),ctx.callbacks.afterNodeAdded(newElt),added.push(newElt)}}for(let removedElement of removed)if(ctx.callbacks.beforeNodeRemoved(removedElement)!==!1)oldHead.removeChild(removedElement),ctx.callbacks.afterNodeRemoved(removedElement);return ctx.head.afterHeadMorphed(oldHead,{added,kept:preserved,removed}),promises}let createMorphContext=function(){function createMorphContext2(oldNode,newContent,config){let{persistentIds,idMap}=createIdMaps(oldNode,newContent),mergedConfig=mergeDefaults(config),morphStyle=mergedConfig.morphStyle||"outerHTML";if(!["innerHTML","outerHTML"].includes(morphStyle))throw`Do not understand how to morph style ${morphStyle}`;return{target:oldNode,newContent,config:mergedConfig,morphStyle,ignoreActive:mergedConfig.ignoreActive,ignoreActiveValue:mergedConfig.ignoreActiveValue,restoreFocus:mergedConfig.restoreFocus,idMap,persistentIds,pantry:createPantry(),activeElementAndParents:createActiveElementAndParents(oldNode),callbacks:mergedConfig.callbacks,head:mergedConfig.head}}function mergeDefaults(config){let finalConfig=Object.assign({},defaults);return Object.assign(finalConfig,config),finalConfig.callbacks=Object.assign({},defaults.callbacks,config.callbacks),finalConfig.head=Object.assign({},defaults.head,config.head),finalConfig}function createPantry(){let pantry=document.createElement("div");return pantry.hidden=!0,document.body.insertAdjacentElement("afterend",pantry),pantry}function createActiveElementAndParents(oldNode){let activeElementAndParents=[],elt=document.activeElement;if(elt?.tagName!=="BODY"&&oldNode.contains(elt))while(elt){if(activeElementAndParents.push(elt),elt===oldNode)break;elt=elt.parentElement}return activeElementAndParents}function findIdElements(root){let elements=Array.from(root.querySelectorAll("[id]"));if(root.getAttribute?.("id"))elements.push(root);return elements}function populateIdMapWithTree(idMap,persistentIds,root,elements){for(let elt of elements){let id=elt.getAttribute("id");if(persistentIds.has(id)){let current=elt;while(current){let idSet=idMap.get(current);if(idSet==null)idSet=new Set,idMap.set(current,idSet);if(idSet.add(id),current===root)break;current=current.parentElement}}}}function createIdMaps(oldContent,newContent){let oldIdElements=findIdElements(oldContent),newIdElements=findIdElements(newContent),persistentIds=createPersistentIds(oldIdElements,newIdElements),idMap=new Map;populateIdMapWithTree(idMap,persistentIds,oldContent,oldIdElements);let newRoot=newContent.__idiomorphRoot||newContent;return populateIdMapWithTree(idMap,persistentIds,newRoot,newIdElements),{persistentIds,idMap}}function createPersistentIds(oldIdElements,newIdElements){let duplicateIds=new Set,oldIdTagNameMap=new Map;for(let{id,tagName}of oldIdElements)if(oldIdTagNameMap.has(id))duplicateIds.add(id);else oldIdTagNameMap.set(id,tagName);let persistentIds=new Set;for(let{id,tagName}of newIdElements)if(persistentIds.has(id))duplicateIds.add(id);else if(oldIdTagNameMap.get(id)===tagName)persistentIds.add(id);for(let id of duplicateIds)persistentIds.delete(id);return persistentIds}return createMorphContext2}(),{normalizeElement,normalizeParent}=function(){let generatedByIdiomorph=new WeakSet;function normalizeElement2(content){if(content instanceof Document)return content.documentElement;else return content}function normalizeParent2(newContent){if(newContent==null)return document.createElement("div");else if(typeof newContent==="string")return normalizeParent2(parseContent(newContent));else if(generatedByIdiomorph.has(newContent))return newContent;else if(newContent instanceof Node)if(newContent.parentNode)return new SlicedParentNode(newContent);else{let dummyParent=document.createElement("div");return dummyParent.append(newContent),dummyParent}else{let dummyParent=document.createElement("div");for(let elt of[...newContent])dummyParent.append(elt);return dummyParent}}class SlicedParentNode{constructor(node){this.originalNode=node,this.realParentNode=node.parentNode,this.previousSibling=node.previousSibling,this.nextSibling=node.nextSibling}get childNodes(){let nodes=[],cursor=this.previousSibling?this.previousSibling.nextSibling:this.realParentNode.firstChild;while(cursor&&cursor!=this.nextSibling)nodes.push(cursor),cursor=cursor.nextSibling;return nodes}querySelectorAll(selector){return this.childNodes.reduce((results,node)=>{if(node instanceof Element){if(node.matches(selector))results.push(node);let nodeList=node.querySelectorAll(selector);for(let i=0;i<nodeList.length;i++)results.push(nodeList[i])}return results},[])}insertBefore(node,referenceNode){return this.realParentNode.insertBefore(node,referenceNode)}moveBefore(node,referenceNode){return this.realParentNode.moveBefore(node,referenceNode)}get __idiomorphRoot(){return this.originalNode}}function parseContent(newContent){let parser=new DOMParser,contentWithSvgsRemoved=newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,"");if(contentWithSvgsRemoved.match(/<\/html>/)||contentWithSvgsRemoved.match(/<\/head>/)||contentWithSvgsRemoved.match(/<\/body>/)){let content=parser.parseFromString(newContent,"text/html");if(contentWithSvgsRemoved.match(/<\/html>/))return generatedByIdiomorph.add(content),content;else{let htmlElement=content.firstChild;if(htmlElement)generatedByIdiomorph.add(htmlElement);return htmlElement}}else{let content=parser.parseFromString("<body><template>"+newContent+"</template></body>","text/html").body.querySelector("template").content;return generatedByIdiomorph.add(content),content}}return{normalizeElement:normalizeElement2,normalizeParent:normalizeParent2}}();return{morph,defaults}}();var host=document.createElement("tensaku-overlay");host.id="tensaku-overlay";host.setAttribute("data-tensaku-ui","");host.setAttribute("data-turbo-permanent","");var shadow=host.attachShadow({mode:"open"}),style=document.createElement("style");style.textContent=`
2
+ :host { all: initial; }
3
+ * { box-sizing: border-box; }
4
+ [hidden] { display: none !important; }
5
+ .layer { position: absolute; left: 0; top: 0; width: 0; height: 0; z-index: 2147483000; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
6
+ .fixed { position: fixed; inset: 0; pointer-events: none; z-index: 2147483001; font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: var(--ink); }
7
+ .fixed > * { pointer-events: auto; }
8
+ :host {
9
+ --paper: #fffdf8; --ink: #22201c; --muted: #6f6a60; --line: #ddd6c8;
10
+ --akaji: #c41e3a; --akaji-soft: rgba(196, 30, 58, .10); --pencil: #4b5563; --pencil-soft: rgba(75, 85, 99, .10);
11
+ --question: #1d4ed8; --resolved: #15803d;
12
+ }
13
+ @media (prefers-color-scheme: dark) {
14
+ :host { --paper: #1f1d1a; --ink: #f3efe6; --muted: #aaa294; --line: #3b3731; --akaji: #ff5d73; --akaji-soft: rgba(255, 93, 115, .14); --pencil: #c4c9d1; --pencil-soft: rgba(196, 201, 209, .14); --question: #7aa2ff; --resolved: #4ade80; }
15
+ }
16
+ button { font: inherit; color: inherit; background: none; border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; cursor: pointer; }
17
+ button:hover { border-color: var(--ink); }
18
+ button.primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
19
+ button.link { border: none; padding: 0; text-decoration: underline; color: var(--muted); }
20
+ textarea, input { font: inherit; color: var(--ink); background: var(--paper); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; width: 100%; }
21
+ textarea:focus, input:focus { outline: 2px solid var(--pencil); outline-offset: -1px; }
22
+
23
+ .box { position: absolute; pointer-events: none; border: 1.5px solid var(--pencil); background: var(--pencil-soft); border-radius: 3px; }
24
+ .box.edit { border: 1.5px dashed var(--akaji); background: transparent; }
25
+ .box.hover-edit { border-color: var(--akaji); background: var(--akaji-soft); }
26
+ .box.selected { border-width: 2px; }
27
+ .label { position: absolute; transform: translateY(-100%); background: var(--ink); color: var(--paper); font-size: 11px; padding: 1px 6px; border-radius: 4px 4px 4px 0; white-space: nowrap; pointer-events: none; max-width: 520px; overflow: hidden; text-overflow: ellipsis; }
28
+ .area { position: absolute; border: 1.5px dashed var(--pencil); background: var(--pencil-soft); pointer-events: none; }
29
+
30
+ .panel { position: absolute; background: var(--paper); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 12px 32px rgba(0,0,0,.18); padding: 10px; min-width: 260px; max-width: 460px; }
31
+ .panel h3 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); }
32
+ .panel .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
33
+ .panel .row.end { justify-content: flex-end; }
34
+ .panel .meta { font-size: 11px; color: var(--muted); word-break: break-all; }
35
+ .panel .warn { font-size: 12px; color: var(--akaji); margin-top: 6px; }
36
+ .menu { display: flex; flex-direction: column; gap: 4px; min-width: 200px; }
37
+ .menu button { text-align: left; border: none; padding: 5px 8px; }
38
+ .menu button:hover { background: var(--pencil-soft); }
39
+ .menu kbd { float: right; color: var(--muted); font: 11px ui-monospace, monospace; }
40
+
41
+ .editor { padding: 0; min-width: 0; max-width: none; border-color: var(--akaji); }
42
+ .editor .surface { padding: 6px 8px; white-space: pre-wrap; outline: none; min-height: 1.4em; }
43
+ .editor .bar { display: flex; gap: 6px; align-items: center; justify-content: space-between; border-top: 1px solid var(--line); padding: 5px 8px; font: 11px/1.3 ui-sans-serif, system-ui, sans-serif; color: var(--muted); }
44
+ .editor .bar .actions { display: flex; gap: 6px; }
45
+ .editor .bar button { font-size: 11px; padding: 2px 7px; }
46
+ .editable { outline: none; border-bottom: 1px dotted var(--akaji); min-width: .5em; display: inline; }
47
+ .editable:focus { background: var(--akaji-soft); }
48
+ .chip { display: inline-block; font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); background: var(--pencil-soft); border: 1px solid var(--line); border-radius: 4px; padding: 0 4px; margin: 0 1px; vertical-align: baseline; user-select: none; cursor: default; white-space: nowrap; }
49
+ .locale { display: grid; grid-template-columns: 32px 1fr; gap: 4px 6px; align-items: start; padding: 6px 8px; border-top: 1px solid var(--line); font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; }
50
+ .locale .code { font: 11px ui-monospace, monospace; color: var(--muted); padding-top: 2px; }
51
+ .attr { display: grid; grid-template-columns: 90px 1fr; gap: 6px; align-items: baseline; margin-top: 6px; }
52
+ .attr .name { font: 11px ui-monospace, monospace; color: var(--muted); }
53
+ .attr .value { border: 1px solid var(--line); border-radius: 6px; padding: 4px 6px; white-space: pre-wrap; }
54
+
55
+ .pin { position: absolute; width: 22px; height: 22px; border-radius: 11px 11px 11px 2px; background: var(--pencil); color: var(--paper); font: 600 11px/22px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.25); transform: translate(-4px, -100%); }
56
+ .pin.question { background: var(--question); }
57
+ .pin.resolved { background: var(--resolved); }
58
+ .note-card .comment { white-space: pre-wrap; }
59
+ .note-card .reply { margin-top: 6px; padding: 6px 8px; border-left: 3px solid var(--resolved); background: var(--pencil-soft); white-space: pre-wrap; }
60
+ .note-card .reply.question { border-color: var(--question); }
61
+ .status { font: 600 10px/1 ui-sans-serif, system-ui, sans-serif; text-transform: uppercase; letter-spacing: .04em; padding: 2px 5px; border-radius: 4px; background: var(--pencil-soft); color: var(--pencil); }
62
+ .status.question { color: var(--question); }
63
+ .status.resolved { color: var(--resolved); }
64
+
65
+ .dock { position: fixed; display: flex; align-items: center; gap: 6px; user-select: none; }
66
+ .corner { width: 36px; height: 36px; border-radius: 18px; border: 1px solid var(--line); background: var(--paper); color: var(--akaji); font: 600 17px/34px "Hiragino Mincho ProN", "Yu Mincho", serif; text-align: center; cursor: grab; box-shadow: 0 4px 14px rgba(0,0,0,.18); position: relative; padding: 0; }
67
+ .corner.active { background: var(--akaji); color: var(--paper); border-color: var(--akaji); }
68
+ .corner .count { position: absolute; top: -4px; right: -4px; min-width: 16px; height: 16px; border-radius: 8px; background: var(--pencil); color: var(--paper); font: 600 10px/16px ui-sans-serif, system-ui, sans-serif; padding: 0 4px; }
69
+ .toolbar { display: flex; gap: 4px; background: var(--paper); border: 1px solid var(--line); border-radius: 18px; padding: 4px; box-shadow: 0 4px 14px rgba(0,0,0,.14); }
70
+ .toolbar button { border: none; border-radius: 14px; padding: 4px 10px; }
71
+ .toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
72
+ .sidebar { position: fixed; top: 12px; right: 12px; bottom: 64px; width: 360px; overflow: auto; background: var(--paper); border: 1px solid var(--line); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,.18); padding: 12px; }
73
+ .sidebar header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
74
+ .sidebar header strong { font-size: 14px; }
75
+ .sidebar .note-card { border-top: 1px solid var(--line); padding: 10px 0; }
76
+ .sidebar .empty { color: var(--muted); padding: 12px 0; }
77
+ .toasts { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; flex-direction: column; gap: 6px; align-items: center; }
78
+ .toast { background: var(--ink); color: var(--paper); border-radius: 8px; padding: 7px 12px; box-shadow: 0 6px 18px rgba(0,0,0,.2); display: flex; gap: 10px; align-items: center; max-width: 560px; }
79
+ .toast.error { background: var(--akaji); }
80
+ .toast button { color: inherit; border-color: currentColor; padding: 1px 7px; font-size: 12px; }
81
+ `;var layer=div("layer"),fixed=div("fixed"),toasts=div("toasts");shadow.append(style,layer,fixed);fixed.append(toasts);function mount(){if(!host.isConnected)document.documentElement.append(host)}function h(tag,attributes={},...children){let element=document.createElement(tag);for(let[name,value]of Object.entries(attributes)){if(value===void 0||value===null||value===!1)continue;if(name.startsWith("on")&&typeof value==="function")element.addEventListener(name.slice(2),value);else if(name==="className")element.className=value;else element.setAttribute(name,value===!0?"":String(value))}for(let child of children)if(child)element.append(child);return element}function div(className,...children){return h("div",{className},...children)}function toast(message,options={}){let element=div("toast"+(options.error?" error":""),h("span",{},message));element.setAttribute("role",options.error?"alert":"status");for(let[label,action]of Object.entries(options.actions??{}))element.append(h("button",{onclick:()=>{element.remove(),action()}},label));return toasts.append(element),setTimeout(()=>element.remove(),options.timeout??(options.error?8000:5000)),element}function panel(anchor,className,...children){let element=div("panel "+className,...children);return layer.append(element),place(element,anchor),element}function place(element,anchor,over=!1){let width=element.offsetWidth,left=Math.max(scrollX+8,Math.min(anchor.left,scrollX+innerWidth-width-8)),top=over?anchor.top:anchor.bottom+6;if(!over&&top+element.offsetHeight>scrollY+innerHeight-8&&anchor.top-element.offsetHeight-6>scrollY)top=anchor.top-element.offsetHeight-6;element.style.left=`${left}px`,element.style.top=`${top}px`}function box(rect,className,label){let element=div("box "+className);if(Object.assign(element.style,{left:`${rect.left}px`,top:`${rect.top}px`,width:`${rect.width}px`,height:`${rect.height}px`}),layer.append(element),!label)return[element];let tag=div("label");return tag.textContent=label,Object.assign(tag.style,{left:`${rect.left}px`,top:`${rect.top}px`}),layer.append(tag),[element,tag]}async function copy(text){try{await navigator.clipboard.writeText(text)}catch{let area=h("textarea",{},text);shadow.append(area),area.select(),document.execCommand("copy"),area.remove()}}function storage(key,fallback){try{let value=localStorage.getItem(`tensaku:${key}`);return value?JSON.parse(value):fallback}catch{return fallback}}function store(key,value){try{localStorage.setItem(`tensaku:${key}`,JSON.stringify(value))}catch{}}function activeInShadow(){return shadow.activeElement}var listeners=[];function onRefresh(listener){listeners.push(listener)}function permanent(node){return node instanceof Element&&(node.hasAttribute("data-turbo-permanent")||node.hasAttribute("data-tensaku-permanent")||node.hasAttribute("data-tensaku-ui"))}function herbDevServer(){return Boolean(document.querySelector('meta[name="herb-dev-server-port"]'))}function replaceText(nodes,from,to){let normal=(text)=>text.replace(/\s+/g," ").trim(),target=normal(from);if(!target)return;for(let node of nodes)if(normal(node.data)===target){let lead=node.data.match(/^\s*/)[0],trail=node.data.match(/\s*$/)[0];node.data=lead+to.trim()+trail}else if(node.data.includes(from)&&from.trim())node.data=node.data.replace(from,to)}async function refresh(kind){if(meta().method!=="GET"){toast("Saved. This page came from a form submission, so reload to check it.",{timeout:7000});return}if(kind==="template"&&herbDevServer())return;try{let response=await fetch(location.href,{headers:{Accept:"text/html","X-Tensaku-Refresh":"1"},credentials:"same-origin"});if(!response.ok)throw Error(`HTTP ${response.status}`);let html=await response.text(),next=new DOMParser().parseFromString(html,"text/html");morph(next)}catch(error){toast(`Saved, but refreshing the page failed (${error.message}). Reload to check.`,{error:!0})}}function morph(next){let lazyFrames=Array.from(document.querySelectorAll("turbo-frame[src]"));if(Idiomorph.morph(document.body,next.body,{morphStyle:"outerHTML",ignoreActiveValue:!0,restoreFocus:!0,callbacks:{beforeNodeMorphed:(oldNode)=>!permanent(oldNode),beforeNodeRemoved:(node)=>!permanent(node)}}),next.title)document.title=next.title;for(let frame of lazyFrames)if(frame.isConnected&&typeof frame.reload==="function"&&!frame.hasAttribute("complete"))frame.reload();listeners.forEach((listener)=>listener())}var undos=new Map,open=null;function undoFor(element){return undos.get(undoKey(element))}function undoKey(element){return element.getAttribute(STAMP)??element.getAttribute(MARKDOWN)??translationOf(element)?.key??""}function isEditing(){return open!==null}function closeEditor(){open?.close()}async function edit(element,kind=editKind(element)){closeEditor();try{if(kind==="translation")await editTranslation(element,translationOf(element));else if(kind==="markdown")await editMarkdown(element);else if(kind==="template")await editTemplate(element);else if(kind==="attributes")await editAttributes(element);else toast("This text comes from Ruby, so it can't be edited here. Leave a note instead.")}catch(error){report(error)}}function report(error){let message=error instanceof Error?error.message:String(error);toast(message,{error:!0,actions:error instanceof ApiError&&error.stale?{Reload:()=>location.reload()}:void 0})}var normal=(text)=>text.replace(/\s+/g," ").trim();function assertRendered(element,texts){let rendered=normal(element.textContent??""),from=0;for(let text of texts.map(normal).filter(Boolean)){let at=rendered.indexOf(text,from);if(at<0)throw new ApiError("The file changed since the page was rendered. Reload the page and try again.",409,!0);from=at+text.length}}function field(text,multiline=!1){let span=h("span",{className:"editable",spellcheck:"true"},text);if(span.contentEditable="plaintext-only",span.contentEditable!=="plaintext-only")span.contentEditable="true";if(span.dataset.original=text,span.addEventListener("paste",(event)=>{event.preventDefault(),document.execCommand("insertText",!1,event.clipboardData?.getData("text/plain")??"")}),!multiline)span.addEventListener("keydown",(event)=>{if(event.key==="Enter"&&event.shiftKey)event.preventDefault()});return span}var valueOf=(span)=>(span.innerText??span.textContent??"").replace(/ /g," ").replace(/\n$/,"");function chip(label,title){return h("span",{className:"chip",title,contenteditable:"false"},label)}function frame(element,surface,info,save,extra=[],warning){let rect=contentRect(element),computed=getComputedStyle(element);Object.assign(surface.style,{fontFamily:computed.fontFamily,fontSize:computed.fontSize,fontWeight:computed.fontWeight,lineHeight:computed.lineHeight,letterSpacing:computed.letterSpacing,textAlign:computed.textAlign,color:"var(--ink)"}),surface.classList.add("surface");let status=h("span",{},info),close=()=>{container.remove(),open=null,document.removeEventListener("keydown",keys,!0)},saveAndClose=async()=>{if(container.dataset.saving)return;container.dataset.saving="1",status.textContent="Saving…";try{await save(),close()}catch(error){delete container.dataset.saving,status.textContent=info,report(error)}},container=div("panel editor",surface,...extra,div("bar",h("span",{},status,warning?h("strong",{style:"color: var(--akaji)"},` · ${warning}`):null),div("actions",h("button",{onclick:close,type:"button"},"Cancel"),h("button",{className:"primary",onclick:saveAndClose,type:"button"},"Save"))));container.setAttribute("role","dialog"),container.setAttribute("aria-label","Tensaku editor"),container.style.minWidth=`${Math.max(rect.width,240)}px`,container.style.maxWidth=`${Math.max(rect.width+40,480)}px`,layer.append(container),place(container,rect,!0);let keys=(event)=>{if(!container.isConnected)return;if(event.key==="Escape")event.preventDefault(),event.stopPropagation(),close();if(event.key==="Enter"&&!event.shiftKey&&activeInShadow()?.classList.contains("editable"))event.preventDefault(),saveAndClose()};return document.addEventListener("keydown",keys,!0),container.addEventListener("focusout",()=>{setTimeout(()=>{let active=activeInShadow();if(container.isConnected&&!container.dataset.saving&&!(active&&container.contains(active)))saveAndClose()},150)}),open={element:container,save:saveAndClose,close},container.querySelector(".editable")?.focus(),container}function pieceSurface(pieces){let surface=div(""),fields=[];for(let piece of pieces)if(piece.kind==="text"){let span=field(piece.text);fields.push({span,piece}),surface.append(span)}else if(piece.kind==="ruby")surface.append(chip(piece.code.length>30?piece.code.slice(0,29)+"…":piece.code,piece.code));else if(piece.kind==="element")surface.append(chip(`<${piece.tag}>${piece.label?" "+piece.label:""}`,"Edit this element on its own"));return{surface,fields}}async function editTemplate(element){let stamp=stampOf(element),info=await api("GET","/source"+query({stamp:stamp.value}));if(indexPieces(info.pieces),!info.editable){if(editableAttributes(element).length)return editAttributes(element);throw Error(info.helper?"This element comes from a helper, so its text can't be edited here.":"This element has no static text to edit.")}assertRendered(element,info.pieces.filter((p)=>p.kind==="text").map((p)=>p.text));let{surface,fields}=pieceSurface(info.pieces),copies=copiesOf(element);frame(element,surface,`${info.file}:${stamp.line}`,async()=>{let changed=fields.filter(({span,piece})=>valueOf(span)!==piece.text);if(!changed.length)return;await saveTemplate(element,stamp.value,changed.map(({span,piece})=>({piece,text:valueOf(span)})))},[],copies>1?`this edits all ${copies} copies`:void 0)}async function saveTemplate(element,stamp,changes,isUndo=!1){let result=await api("POST","/text",{stamp,edits:changes.map(({piece,text})=>({start:piece.start,end:piece.end,original:piece.raw,text}))}),nodes=Array.from(document.querySelectorAll(`[${STAMP}="${CSS.escape(stamp)}"]`)).flatMap((el)=>Array.from(el.childNodes).filter((node)=>node.nodeType===Node.TEXT_NODE));if(changes.forEach(({piece,text})=>replaceText(nodes,piece.text,text)),!isUndo)remember(element,`Undo edit in ${stamp}`,async()=>{let texts=(await api("GET","/source"+query({stamp}))).pieces.filter((p)=>p.kind==="text"),reverse=changes.map(({piece,text})=>{let now=texts[piece.index??-1];if(!now||now.text!==text)throw new ApiError("The text changed since the edit, so it can't be undone here.",409,!0);return{piece:{...now,index:piece.index},text:piece.text}});await saveTemplate(element,stamp,reverse,!0)});done(result,"template")}async function editAttributes(element){let stamp=stampOf(element),description=stamp?await api("GET","/source"+query({stamp:stamp.value})):null,names=editableAttributes(element),rows=[],savers=[];for(let name of names){let key=attributeKey(element,name);if(key){let entry=(await api("GET","/translation"+query({key:key.key}))).locales[key.locale];if(!entry?.editable||entry.value===null)continue;assertRendered(attributeNode(element,name),staticParts(entry.value));let{surface:surface3,read}=interpolationSurface(entry.value);rows.push(div("attr",h("span",{className:"name",title:`${key.locale}.${key.key}`},`${name} (t)`),div("value",surface3))),savers.push(async()=>{let text=read();if(text!==entry.value)await saveTranslation(element,key,entry.value,text)});continue}let attribute=description?.attributes.find((a)=>a.name===name);if(!attribute||!attribute.pieces.some((p)=>p.kind==="text"))continue;assertRendered(attributeNode(element,name),attribute.pieces.filter((p)=>p.kind==="text").map((p)=>p.text));let{surface:surface2,fields}=pieceSurface(attribute.pieces);rows.push(div("attr",h("span",{className:"name"},name),div("value",surface2))),savers.push(async()=>{for(let{span,piece}of fields){let text=valueOf(span);if(text===piece.text)continue;let result=await api("POST","/attribute",{stamp:stamp.value,name,start:piece.start,end:piece.end,original:piece.raw,text});document.querySelectorAll(`[${STAMP}="${CSS.escape(stamp.value)}"]`).forEach((el)=>{let current=el.getAttribute(name);if(current!==null)el.setAttribute(name,current.replace(piece.text,text))}),remember(element,`Undo ${name}`,async()=>{let now=(await api("GET","/source"+query({stamp:stamp.value}))).attributes.find((a)=>a.name===name)?.pieces.find((p)=>p.kind==="text"&&p.text===text);if(!now)throw new ApiError("The attribute changed since the edit, so it can't be undone here.",409,!0);await api("POST","/attribute",{stamp:stamp.value,name,start:now.start,end:now.end,original:now.raw,text:piece.text}),done({changed:!0},"template")}),done(result,"template");return}})}if(!rows.length)throw Error("Nothing here can be edited. Leave a note instead.");let surface=div("");surface.append(...rows),frame(element,surface,description?`${description.file}:${stamp.line} attributes`:"attributes",async()=>{for(let save of savers)await save()})}var INTERPOLATION=/(%\{[^}]+\})/;function interpolationSurface(value){let surface=div("");for(let part of value.split(INTERPOLATION)){if(!part)continue;surface.append(INTERPOLATION.test(part)?chip(part):field(part,!0))}if(!surface.querySelector(".editable"))surface.prepend(field("",!0));return{surface,read:()=>Array.from(surface.childNodes).map((node)=>node.classList?.contains("chip")?node.textContent:valueOf(node)).join("")}}async function editTranslation(element,key){let{locales}=await api("GET","/translation"+query({key:key.key})),entry=locales[key.locale];if(!entry?.editable||entry.value===null)throw Error(`${key.locale}.${key.key} isn't in a writable locale file (it may come from a gem).`);assertRendered(element,staticParts(entry.value));let main=interpolationSurface(entry.value),others=Object.entries(locales).filter(([locale])=>locale!==key.locale),otherRows=div("");otherRows.hidden=!0;let otherReaders=[];for(let[locale,other]of others)if(other.editable&&other.value!==null){let surface=interpolationSurface(other.value);otherReaders.push({locale,entry:other,read:surface.read}),otherRows.append(div("locale",h("span",{className:"code"},locale),surface.surface))}else otherRows.append(div("locale",h("span",{className:"code"},locale),h("em",{style:"color: var(--muted)"},other.value===null?"missing":`${other.value} (not writable)`)));let toggle=others.length?h("button",{className:"link",type:"button",onclick:()=>{otherRows.hidden=!otherRows.hidden,toggle.textContent=otherRows.hidden?`Other locales (${others.length})`:"Hide other locales"}},`Other locales (${others.length})`):null,extra=toggle?[div("locale",h("span",{className:"code"},""),toggle),otherRows]:[];frame(element,main.surface,`${key.locale}.${key.key} · ${entry.file}:${entry.line}`,async()=>{let text=main.read();if(text!==entry.value)await saveTranslation(element,key,entry.value,text);for(let other of otherReaders){let value=other.read();if(value!==other.entry.value)await api("POST","/translation",{locale:other.locale,key:key.key,original:other.entry.value,text:value})}},extra)}async function saveTranslation(element,key,original,text,isUndo=!1){let result=await api("POST","/translation",{locale:key.locale,key:key.key,original,text});if(!INTERPOLATION.test(original))replaceText(nodesForKey(key),original,text),elementsWithAttributeKey(key).forEach(({element:el,name})=>{if(el.getAttribute(name)===original)el.setAttribute(name,text)});if(!isUndo)remember(element,`Undo ${key.key}`,()=>saveTranslation(element,key,text,original,!0));done(result,"translation")}async function editMarkdown(element){let range=element.getAttribute(MARKDOWN),info=await api("GET","/markdown"+query({range,tag:element.tagName.toLowerCase()}));if(!info.editable)throw Error("This Markdown block can only get notes.");assertRendered(element,[info.text.replace(/\]\([^)]*\)/g,"").replace(/[*_`#>\[\]]/g,"").split(/\s+/).slice(0,6).join(" ")]);let surface=div("");if(info.prefix.trim())surface.append(chip(info.prefix.trim())," ");let text=field(info.text,!0);surface.append(text),frame(element,surface,`${info.file}:${info.range.split(":")[0]} (Markdown)`,async()=>{let value=valueOf(text);if(value===info.text)return;await saveMarkdown(element,range,info.original,info.prefix,value,info.text)})}async function saveMarkdown(element,range,original,prefix,text,previous,isUndo=!1){let result=await api("POST","/markdown",{range,original,text});if(!/[*_`\[\]<>#]/.test(previous+text))replaceText(Array.from(element.childNodes).filter((n)=>n.nodeType===Node.TEXT_NODE),previous,text);if(!isUndo)remember(element,"Undo Markdown edit",async()=>{let[,path,startLine,startColumn]=range.match(/^(.+):(\d+):(\d+)-/),source=prefix+text,lines=source.split(`
82
+ `),endLine=Number(startLine)+lines.length-1,endColumn=(lines.length===1?Number(startColumn)-1:0)+utf8Length(lines[lines.length-1]);await saveMarkdown(element,`${path}:${startLine}:${startColumn}-${endLine}:${endColumn}`,source,prefix,previous,text,!0)});done(result,"markdown")}function remember(element,label,run){let key=undoKey(element);undos.set(key,{label,run:async()=>{undos.delete(key),await run()}})}function done(result,kind){if(!result.changed)return;let last=Array.from(undos.values()).pop();if(toast(`Saved ${result.file??""}`.trim(),{actions:last?{Undo:()=>last.run().catch(report)}:void 0}),result.warning)toast(result.warning,{timeout:8000});refresh(kind)}function undo(element){undoFor(element)?.run().catch(report)}function indexPieces(pieces){let index=0;for(let piece of pieces)if(piece.kind==="text")piece.index=index++}var staticParts=(value)=>value.split(INTERPOLATION).filter((part)=>part&&!INTERPOLATION.test(part));function attributeNode(element,name){let node=document.createElement("span");return node.textContent=element.getAttribute(name)??"",node}var notes=[],openCount=0,pinLayer=div("pins");layer.append(pinLayer);var countListeners=[],onCount=(listener)=>countListeners.push(listener);function targetsFor(element,selectedText){let stamp=nearestStamp(element),targets=[{kind:selectedText?"text":"element",stamp:stamp?.value,text:selectedText||visibleText(element,300),tag:tagLabel(element),context:contextOf(element)}],key=translationOf(element)??textKeys(element)[0]?.key;if(key)targets.push({kind:"translation",key:key.key,locale:key.locale,text:visibleText(element,300)});let md=element.closest(`[${MARKDOWN}]`)?.getAttribute(MARKDOWN);if(md)targets.push({kind:"markdown",md,text:visibleText(element,300),tag:tagLabel(element)});return targets}function elementsInArea(rect){let candidates=Array.from(document.querySelectorAll(`[${STAMP}], [${MARKDOWN}]`)).filter((element)=>{if(element===document.body||element.closest("[data-tensaku-ui]"))return!1;let box2=contentRect(element);if(!box2.width||!box2.height)return!1;return Math.max(0,Math.min(box2.right,rect.right)-Math.max(box2.left,rect.left))*Math.max(0,Math.min(box2.bottom,rect.bottom)-Math.max(box2.top,rect.top))/(box2.width*box2.height)>=0.5}),outermost=candidates.filter((element)=>!candidates.some((other)=>other!==element&&other.contains(element)));if(outermost.length)return outermost.slice(0,20);let fallback=document.elementFromPoint(rect.left+rect.width/2-scrollX,rect.top+rect.height/2-scrollY)?.closest(`[${STAMP}], [${MARKDOWN}]`);return fallback?[fallback]:[]}function noteForm(anchor,targets,extra={},onDone){let textarea=h("textarea",{rows:3,placeholder:"What should change?","aria-label":"Note"}),where=targets.filter((t)=>t.kind!=="translation"&&t.kind!=="markdown").map((t)=>t.stamp??t.tag).filter(Boolean),close=()=>{element.remove(),onDone?.()},save=async()=>{let comment=textarea.value.trim();if(!comment)return textarea.focus();try{await api("POST","/notes",{url:location.pathname,comment,targets,viewport:`${innerWidth}x${innerHeight}`,...extra}),close(),toast("Note saved"),await load()}catch(error){toast(error.message,{error:!0})}};textarea.addEventListener("keydown",(event)=>{if(event.key==="Enter"&&(event.metaKey||event.ctrlKey))event.preventDefault(),save();if(event.key==="Escape")event.preventDefault(),event.stopPropagation(),close()});let element=panel(anchor,"note-form",h("h3",{},extra.rect?`Note on an area (${targets.length} ${targets.length===1?"element":"elements"})`:"Note"),textarea,div("meta",where.slice(0,3).join(", ")+(where.length>3?` and ${where.length-3} more`:"")),div("row end",h("button",{type:"button",onclick:close},"Cancel"),h("button",{type:"button",className:"primary",onclick:save},"Save note")));return textarea.focus(),element}async function load(){try{let data=await api("GET","/notes"+query({url:location.pathname}));notes=data.notes,openCount=data.open}catch{notes=[]}if(countListeners.forEach((listener)=>listener(openCount)),drawPins(),sidebar?.isConnected)renderSidebar()}function anchorFor(note){if(note.rect)return new DOMRect(note.rect.x,note.rect.y,note.rect.width,note.rect.height);for(let target of note.targets){let selector=target.stamp?`[${STAMP}="${CSS.escape(target.stamp)}"]`:target.md?`[${MARKDOWN}="${CSS.escape(target.md)}"]`:null,element=selector&&document.querySelector(selector);if(element)return contentRect(element)}return null}function drawPins(){pinLayer.replaceChildren(),notes.forEach((note,index)=>{let rect=anchorFor(note);if(!rect)return;let pin=h("button",{className:`pin ${note.status}`,title:note.comment,"aria-label":`Note ${index+1}: ${note.comment}`,"data-note":note.id},String(index+1));Object.assign(pin.style,{left:`${rect.right}px`,top:`${rect.top}px`}),pin.addEventListener("click",(event)=>{event.stopPropagation(),showNote(note,rect)}),pinLayer.append(pin)})}function noteCard(note,onChange){let actions=div("row"),update=async(body)=>{await api("PATCH",`/notes/${note.id}`,body),await load(),onChange()},remove=async()=>{await api("DELETE",`/notes/${note.id}`),await load(),onChange()};if(actions.append(h("button",{type:"button",onclick:async()=>{await copy(await api("GET","/notes.md"+query({id:note.id}))),toast("Copied note as Markdown")}},"Copy")),note.status==="resolved")actions.append(h("button",{type:"button",onclick:remove},"Dismiss"),h("button",{type:"button",onclick:()=>update({status:"open"})},"Reopen"));else actions.append(h("button",{type:"button",onclick:()=>update({status:"resolved"})},"Resolve"),h("button",{type:"button",onclick:remove},"Delete"));let answer=note.status==="question"?h("textarea",{rows:2,placeholder:"Answer the question (updates the note)"}):null;if(answer)actions.append(h("button",{type:"button",className:"primary",onclick:()=>answer.value.trim()&&update({status:"open",comment:`${note.comment}
83
+
84
+ Answer: ${answer.value.trim()}`,reply:""})},"Answer"));let files=note.targets.filter((t)=>t.file).map((t)=>`${t.file}${t.range?":"+t.range.split("-")[0]:""}`);return div("note-card",div("row",h("span",{className:`status ${note.status}`},note.status),h("span",{className:"meta"},Array.from(new Set(files)).join(", "))),h("div",{className:"comment",style:"margin-top: 6px"},note.comment),note.reply?h("div",{className:`reply ${note.status}`},note.reply):null,answer,actions)}var shown=null;function showNote(note,rect){shown?.remove(),shown=panel(rect,"",noteCard(note,()=>shown?.remove()));let close=(event)=>{if(!event.composedPath().includes(shown))shown?.remove(),document.removeEventListener("pointerdown",close,!0)};setTimeout(()=>document.addEventListener("pointerdown",close,!0))}var sidebar=null;function toggleSidebar(force){if(!(force??!sidebar?.isConnected)){sidebar?.remove();return}sidebar??=div("sidebar"),sidebar.setAttribute("role","complementary"),sidebar.setAttribute("aria-label","Tensaku notes"),fixed.append(sidebar),renderSidebar()}var sidebarOpen=()=>Boolean(sidebar?.isConnected);function renderSidebar(){if(!sidebar)return;let copyAll=async(all)=>{let markdown=await api("GET","/notes.md"+query(all?{}:{url:location.pathname}));await copy(markdown),toast(all?"Copied all open notes":"Copied this page's open notes")};sidebar.replaceChildren(h("header",{},h("strong",{},`Notes on ${location.pathname}`),h("button",{type:"button",onclick:()=>toggleSidebar(!1),"aria-label":"Close notes"},"×")),div("row",h("button",{type:"button",onclick:()=>copyAll(!1)},"Copy page notes"),h("button",{type:"button",onclick:()=>copyAll(!0)},`Copy all open (${openCount})`)),...notes.length?notes.map((note)=>noteCard(note,()=>{})):[div("empty","No notes on this page. Select an element or drag over an area to add one.")])}var mode="off",hoverBoxes=[],editBoxes=[],menu=null,drag=null,suppressClick=!1,count=h("span",{className:"count",hidden:!0}),corner=h("button",{className:"corner",type:"button",title:"Tensaku","aria-label":"Tensaku","aria-expanded":"false"},"添",count),buttons={inspect:h("button",{type:"button","aria-pressed":"false",title:"Select an element or drag an area"},"Select"),edit:h("button",{type:"button","aria-pressed":"false",title:"Show and edit every editable text"},"Edit"),notes:h("button",{type:"button","aria-pressed":"false"},"Notes")},toolbar=div("toolbar",buttons.inspect,buttons.edit,buttons.notes);toolbar.hidden=!0;var dock=div("dock",toolbar,corner);fixed.append(dock);function placeDock(){let position=storage("dock",{right:16,bottom:16});dock.style.right=`${Math.min(Math.max(position.right,4),innerWidth-44)}px`,dock.style.bottom=`${Math.min(Math.max(position.bottom,4),innerHeight-44)}px`}var dragged=!1;corner.addEventListener("pointerdown",(event)=>{let start={x:event.clientX,y:event.clientY,right:parseFloat(dock.style.right),bottom:parseFloat(dock.style.bottom)};dragged=!1,corner.setPointerCapture(event.pointerId);let move=(e)=>{let dx=e.clientX-start.x,dy=e.clientY-start.y;if(!dragged&&Math.hypot(dx,dy)<4)return;dragged=!0,corner.style.cursor="grabbing",dock.style.right=`${start.right-dx}px`,dock.style.bottom=`${start.bottom-dy}px`},up=()=>{if(corner.removeEventListener("pointermove",move),corner.removeEventListener("pointerup",up),corner.style.cursor="",dragged)store("dock",{right:parseFloat(dock.style.right),bottom:parseFloat(dock.style.bottom)})};corner.addEventListener("pointermove",move),corner.addEventListener("pointerup",up)});corner.addEventListener("click",()=>{if(dragged)return;if(toolbar.hidden=!toolbar.hidden,corner.setAttribute("aria-expanded",String(!toolbar.hidden)),toolbar.hidden)setMode("off")});buttons.inspect.addEventListener("click",()=>setMode(mode==="inspect"?"off":"inspect"));buttons.edit.addEventListener("click",()=>setMode(mode==="edit"?"off":"edit"));buttons.notes.addEventListener("click",()=>{toggleSidebar(),syncButtons()});onCount((n)=>{count.hidden=n===0,count.textContent=String(n),buttons.notes.textContent=n?`Notes (${n})`:"Notes"});function syncButtons(){buttons.inspect.setAttribute("aria-pressed",String(mode==="inspect")),buttons.edit.setAttribute("aria-pressed",String(mode==="edit")),buttons.notes.setAttribute("aria-pressed",String(sidebarOpen())),corner.classList.toggle("active",mode!=="off")}function setMode(next){if(mode=next,next!=="off")toolbar.hidden=!1,corner.setAttribute("aria-expanded","true");clearHover(),closeMenu(),drawEditBoxes(),document.documentElement.style.cursor=next==="inspect"?"crosshair":"",syncButtons()}function clearHover(){hoverBoxes.forEach((b)=>b.remove()),hoverBoxes=[]}function label(element){let stamp=stampOf(element)??nearestStamp(element),md=element.getAttribute(MARKDOWN),key=translationOf(element),where=key?`${key.locale}.${key.key}`:md?md.split(/:(?=\d+:\d+-)/)[0]+":"+md.split(/:(?=\d+:\d+-)/)[1].split(":")[0]:stamp?`${stamp.file}:${stamp.line}`:"";return`${tagLabel(element)} ${where}`}function highlight(element){clearHover(),hoverBoxes=box(documentRect(element),mode==="edit"?"hover-edit":"",label(element))}function editableElements(){let elements=new Set;return document.body.querySelectorAll(`[${STAMP}], [${MARKDOWN}], *`).forEach((element)=>{if(isUi(element)||!element.offsetParent&&getComputedStyle(element).position!=="fixed")return;if(editKind(element))elements.add(element)}),Array.from(elements)}function drawEditBoxes(){if(editBoxes.forEach((b)=>b.remove()),editBoxes=[],mode!=="edit")return;for(let element of editableElements())editBoxes.push(...box(documentRect(element),"edit"))}function closeMenu(){menu?.remove(),menu=null}function reference(element){let stamp=nearestStamp(element),lines=[`${meta().method} ${location.pathname}${location.search} → ${stamp?`${stamp.file}:${stamp.line}:${stamp.column}`:"(no source stamp)"}`],text=visibleText(element,200);lines.push(`<${tagLabel(element)}>${text?` "${text}"`:""}`);let key=translationOf(element);if(key)lines.push(`Translation: ${key.locale}.${key.key}`);let md=element.closest(`[${MARKDOWN}]`)?.getAttribute(MARKDOWN);if(md)lines.push(`Markdown: ${md}`);let context=contextOf(element);if(context.partials.length>1)lines.push(`Partials: ${context.partials.join(" < ")}`);if(context.controller)lines.push(`Stimulus: ${context.controller}`);if(context.frame)lines.push(`Turbo Frame: #${context.frame}`);let copies=copiesOf(element);if(copies>1)lines.push(`Rendered ${copies} times on this page (loop or repeated partial)`);return lines.join(`
85
+ `)}function openMenu(element,selectedText){closeMenu();let rect=documentRect(element),kind=editKind(element),item=(text,action,shortcut)=>h("button",{type:"button",onclick:()=>{closeMenu(),action()}},text,shortcut?h("kbd",{},shortcut):null),undoable=undoFor(element);menu=panel(rect,"menu",h("h3",{},label(element)),item("Copy reference",async()=>{await copy(reference(element)),toast("Copied reference")},"C"),item(selectedText?"Note on selected text":"Add note",()=>noteForm(rect,targetsFor(element,selectedText)),"N"),kind?item(kind==="attributes"?"Edit attributes":"Edit text",()=>edit(element,kind),"E"):null,kind&&kind!=="attributes"&&editKind(element)!=="attributes"&&hasAttributes(element)?item("Edit attributes",()=>edit(element,"attributes")):null,undoable?item(undoable.label,()=>undo(element),"U"):null),menu.dataset.tensakuMenu="",menu.addEventListener("keydown",(event)=>event.stopPropagation()),menu.querySelector("button")?.focus(),menuKeys=(event)=>{if(!menu)return;if({c:0,n:1,e:2,u:3}[event.key.toLowerCase()]===void 0||event.metaKey||event.ctrlKey||event.altKey)return;let match=Array.from(menu.querySelectorAll("button")).find((button)=>button.querySelector("kbd")?.textContent===event.key.toUpperCase());if(match)event.preventDefault(),match.click()},api("POST","/selection",{url:location.pathname,targets:targetsFor(element,selectedText),text:visibleText(element,300)}).catch(()=>{})}var menuKeys=null;function hasAttributes(element){return["alt","title","placeholder","aria-label"].some((name)=>element.hasAttribute(name))}function pageTarget(event){let target=event.composedPath()[0];if(isUi(target)||target instanceof Node&&isUi(target.parentElement))return null;return sourceElement(target)}document.addEventListener("mousemove",(event)=>{if(drag)return updateDrag(event);if(mode==="off"||menu||isEditing())return;let element=pageTarget(event);if(!element)return clearHover();if(mode==="edit"&&!editKind(element))return clearHover();highlight(element)},!0);document.addEventListener("mousedown",(event)=>{if(mode!=="inspect"||event.button!==0||isUi(event.target)||menu||isEditing())return;drag={x:event.pageX,y:event.pageY,area:null}},!0);function updateDrag(event){if(!drag)return;let width=Math.abs(event.pageX-drag.x),height=Math.abs(event.pageY-drag.y);if(!drag.area&&Math.hypot(width,height)<8)return;if(!drag.area)clearHover(),drag.area=div("area"),layer.append(drag.area);Object.assign(drag.area.style,{left:`${Math.min(event.pageX,drag.x)}px`,top:`${Math.min(event.pageY,drag.y)}px`,width:`${width}px`,height:`${height}px`}),event.preventDefault()}document.addEventListener("mouseup",(event)=>{if(!drag)return;let area=drag.area,start=drag;if(drag=null,!area)return;suppressClick=!0;let rect=new DOMRect(Math.min(event.pageX,start.x),Math.min(event.pageY,start.y),Math.abs(event.pageX-start.x),Math.abs(event.pageY-start.y)),elements=elementsInArea(rect),highlights=elements.flatMap((element)=>box(documentRect(element),"selected")),targets=elements.flatMap((element)=>targetsFor(element).slice(0,1));noteForm(rect,targets,{rect:{x:Math.round(rect.x),y:Math.round(rect.y),width:Math.round(rect.width),height:Math.round(rect.height)}},()=>{area.remove(),highlights.forEach((b)=>b.remove())}),api("POST","/selection",{url:location.pathname,targets,text:""}).catch(()=>{})},!0);document.addEventListener("click",(event)=>{if(suppressClick){suppressClick=!1,event.preventDefault(),event.stopPropagation();return}if(isUi(event.target))return;let element=pageTarget(event);if(event.altKey&&element){let kind=editKind(element);if(!kind)return;event.preventDefault(),event.stopImmediatePropagation(),closeMenu(),edit(element,kind);return}if(mode==="off"){if(menu)closeMenu();return}if(event.preventDefault(),event.stopImmediatePropagation(),menu||isEditing()){closeMenu();return}if(!element)return;if(mode==="edit"){let kind=editKind(element);if(kind)edit(element,kind);return}let selection=getSelection()?.toString().trim();openMenu(element,selection&&element.contains(getSelection().anchorNode)?selection:void 0)},!0);function matchesShortcut(event){let parts=meta().shortcut.split("+"),code=parts.pop(),want=new Set(parts.map((p)=>p.toLowerCase()));return event.code===code&&event.altKey===want.has("alt")&&event.shiftKey===want.has("shift")&&event.ctrlKey===want.has("ctrl")&&event.metaKey===(want.has("meta")||want.has("cmd"))}document.addEventListener("keydown",(event)=>{if(matchesShortcut(event)){event.preventDefault(),setMode(mode==="off"?"inspect":"off");return}if(menu&&menuKeys)menuKeys(event);if(event.key==="Escape"){if(menu){closeMenu();return}if(isEditing())return;if(mode!=="off")setMode("off")}},!0);var redraw=0;function scheduleRedraw(){cancelAnimationFrame(redraw),redraw=requestAnimationFrame(()=>{drawPins(),drawEditBoxes()})}addEventListener("resize",()=>{placeDock(),scheduleRedraw()});var path=location.pathname;function pageChanged(){if(closeMenu(),closeEditor(),clearHover(),location.pathname!==path)path=location.pathname;load(),scheduleRedraw()}document.addEventListener("turbo:load",pageChanged);document.addEventListener("turbo:frame-load",scheduleRedraw);addEventListener("popstate",pageChanged);onRefresh(()=>{scheduleRedraw(),load()});setInterval(()=>{if(location.pathname!==path)pageChanged();else drawPins()},1000);function boot(){if(window.__tensaku)return;window.__tensaku=!0,watch(),mount(),placeDock(),load(),new MutationObserver(()=>mount()).observe(document.documentElement,{childList:!0})}boot();})();
@@ -0,0 +1,103 @@
1
+ require "rails/generators"
2
+ require "json"
3
+
4
+ module Tensaku
5
+ module Generators
6
+ # bin/rails generate tensaku:install
7
+ #
8
+ # Checks the Gemfile group, writes the ReActionView initializer, installs the Claude Code skill,
9
+ # adds the MCP endpoint to .mcp.json and makes sure tmp/ is ignored. Prints what it changed.
10
+ class InstallGenerator < Rails::Generators::Base
11
+ desc "Set up Tensaku: ReActionView initializer, Claude Code skill, .mcp.json entry, gitignore check."
12
+
13
+ class_option :port, type: :numeric, default: 3000, desc: "Port of the development server, for .mcp.json"
14
+
15
+ SKILL = File.expand_path("../../../skills/tensaku/SKILL.md", __dir__)
16
+
17
+ def check_gemfile
18
+ gemfile = File.join(destination_root, "Gemfile")
19
+ return unless File.exist?(gemfile)
20
+
21
+ if development_only?(File.read(gemfile))
22
+ say_status :ok, "tensaku is in the development group", :green
23
+ else
24
+ say_status :warning, "Put tensaku in the development group of your Gemfile, so production keeps " \
25
+ "rendering with Erubi and never loads it:\n\n group :development do\n gem \"tensaku\"\n end\n", :yellow
26
+ end
27
+ end
28
+
29
+ def reactionview_initializer
30
+ path = "config/initializers/reactionview.rb"
31
+ if File.exist?(File.join(destination_root, path))
32
+ say_status :exist, "#{path} (Tensaku turns on intercept_erb and source attribution itself)", :blue
33
+ return
34
+ end
35
+
36
+ create_file path, <<~RUBY
37
+ # ReActionView renders ERB with Herb in development. Tensaku turns on ERB interception and adds
38
+ # Herb's source attribution itself; the options here are ReActionView's own.
39
+ # It's only loaded in development (it comes with Tensaku, in the development group).
40
+ if defined?(ReActionView)
41
+ ReActionView.configure do |config|
42
+ # Herb's dev tools (outlines, Open in Editor). They wrap ERB output in display: contents spans.
43
+ config.debug_mode = true
44
+ end
45
+ end
46
+ RUBY
47
+ end
48
+
49
+ def skill
50
+ create_file ".claude/skills/tensaku/SKILL.md", File.read(SKILL)
51
+ end
52
+
53
+ def mcp_json
54
+ path = File.join(destination_root, ".mcp.json")
55
+ config = File.exist?(path) ? JSON.parse(File.read(path)) : {}
56
+ servers = (config["mcpServers"] ||= {})
57
+ entry = { "type" => "http", "url" => "http://localhost:#{options[:port]}/__tensaku/mcp" }
58
+ if servers["tensaku"] == entry
59
+ say_status :identical, ".mcp.json", :blue
60
+ return
61
+ end
62
+
63
+ servers["tensaku"] = entry
64
+ File.write(path, JSON.pretty_generate(config) + "\n")
65
+ say_status :update, ".mcp.json (tensaku: #{entry["url"]})", :green
66
+ end
67
+
68
+ def gitignore
69
+ path = File.join(destination_root, ".gitignore")
70
+ ignored = File.exist?(path) && File.readlines(path, chomp: true).any? { it.strip.match?(%r{\A/?tmp(/|/\*|/\*\*)?\z}) }
71
+ if ignored
72
+ say_status :ok, "tmp/ is ignored, so notes stay local", :green
73
+ elsif File.exist?(path)
74
+ append_to_file ".gitignore", "\n# Tensaku notes\n/tmp/tensaku/\n"
75
+ else
76
+ create_file ".gitignore", "# Tensaku notes\n/tmp/tensaku/\n"
77
+ end
78
+ end
79
+
80
+ def done
81
+ say "\nTensaku is ready. Start the server with bin/dev or bin/rails server, open a page, and press " \
82
+ "Alt+Shift+T (or click the corner button)."
83
+ end
84
+
85
+ private
86
+
87
+ def development_only?(gemfile)
88
+ lines = gemfile.lines
89
+ index = lines.index { it.match?(/^\s*gem\s+["']tensaku["']/) }
90
+ return false unless index
91
+ return true if lines[index].match?(/group:\s*(:development|\[\s*:development\s*\])/)
92
+
93
+ inside = false
94
+ lines.each_with_index do |current, number|
95
+ inside = true if current.match?(/^\s*group\s+:development\s+do/)
96
+ return true if inside && number == index
97
+ inside = false if inside && current.match?(/^\s*end\b/)
98
+ end
99
+ false
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,31 @@
1
+ module Tensaku
2
+ # After a write, the next request has to see the new file. Rails' file watcher would notice too, but
3
+ # only on its next check, and the page refetch right after a save can beat it.
4
+ module Caches
5
+ module_function
6
+
7
+ def clear!(kind)
8
+ case kind
9
+ when :template
10
+ ActionView::LookupContext::DetailsKey.clear
11
+ ViewComponent::CompileCache.invalidate! if defined?(::ViewComponent::CompileCache)
12
+ when :locale
13
+ I18n.reload!
14
+ ActionView::LookupContext::DetailsKey.clear
15
+ when :markdown
16
+ ActionView::LookupContext::DetailsKey.clear
17
+ end
18
+ end
19
+
20
+ # `rails dev:cache` turns on fragment caching, and cached fragments keep showing the old text.
21
+ def fragment_caching?
22
+ !!ActionController::Base.perform_caching
23
+ end
24
+
25
+ def fragment_cache_warning
26
+ return unless fragment_caching?
27
+
28
+ "Fragment caching is on in development (rails dev:cache), so cached fragments can keep showing the old text."
29
+ end
30
+ end
31
+ end