Skip to main content

max / makenotwork

server: describe the media picker, and delete media-picker.js Shape 4 step 1 (6fb46f7c), which has been stopped at the landing since 2026-08-18: the plan's own text says "STOP HERE and report: insertAtCursor and _mediaPickerSelect have no expression. See blockers, this is the finding." That finding was quasicoherent f35aafee, Max ruled (a) on 2026-08-21, and Act::fills shipped in quasi 0.54.0 this morning. So the rest of the step goes in one pass. src/quasi/media_picker.rs is the glue module, three routes on the media router serve it, and four Insert Image buttons across three templates call it. A card is Act::new(filename, Action::local()).filling(into, markdown_ref): the destination is a field name, never a caret, and the press makes no request -- the old one made none either, it just did 155 lines of DOM building instead. FIVE THINGS THE PLAN DID NOT ANTICIPATE. The picker's region is per destination, not per page. item_details.html carries three of these buttons, and one shared `media-picker` id would be three elements answering to it. `trigger` ships the button and its own empty region together, and `into` is gated to a plain handle before an id is built from it -- it arrives on a query string, and what the gate stops is not an injection (the emitter escapes) but a well-formed selector meaning something other than the element it names. A filter answer is aimed at the grid, one region further in than the plan said. Aiming it at the modal replaces the box being typed into and takes the caret with it on every keystroke. The name box also asks through a Consult with a 150ms wait rather than through `changes`, which is discover_typeahead's call for discover_typeahead's reason: a media library is a Postgres round trip and `changes` fires per keystroke. The folder select keeps `changes`, since a select settles rather than being typed into. A modal region needs two boxes and MNW had no rule for either. layout.css has no .region rules at all, so the first described modal on this host would have rendered in flow while claiming role="dialog" aria-modal="true" -- an attribute that would have been a lie. The scrim is the modal region and the panel is a region inside it, because one element cannot be both without its contents being laid out across the viewport. Both rules are in style.css now and every described modal after this one gets them. Three dead one-off button rules go in the same pass: .insert-image-btn, .blog-editor-media-btn and .text-editor-insert-btn were three spellings of the same two declarations. The destination has to be unique in the document, which is new. Act::fills names a Field::name, and a name identifies a field within one description; Filling::id_prefix exists because that stops being true when a document holds two copies of one form, and rich_field leans on it -- five editors, all named `body`, told apart by their ids. So an editor a picker can write into has to be the only `body` in its document. The two surfaces that had a working button already are. item_details.html's two section bodies were raw <textarea> elements with an id and no name at all, addressable by nothing, so they are described now and carry names of their own through the new rich_field::named. Their ids are unchanged, which is what item-details.js reads them by. The shipped picker had three defects and one of them wrote to the database. The JSON field is `filename` and the script read f.file_name, which is undefined: every tile shipped unlabelled and the name filter matched nothing, so typing one character hid the whole library. And markdown_ref is already `![](folder/file.png)`, which _mediaPickerSelect wrapped a second time -- so every insert wrote `![](![](folder/file.png))` into somebody's document. The first two die with the file. The third is filed as 2554d643, because no redeploy repairs a written document. THE GAP IT FOUND, in the other direction. Act is a label, and nothing says a control reads as a picture. Cell::new and Row::new both take a string, so a picture cannot sit beside a control in a table or a list either: Node::Image goes in a region body and nowhere else. A media tile is therefore a region holding two members and only the file name answers a press, which on a picture picker is the affordance. Filed as quasicoherent db998898 with three candidate spellings and the measuring to do first. frontend_globals HIGH_WATER 113 -> 111: window.mediaPickerOpen and window._mediaPickerSelect go with the file.
Wiki
mnw-shape-conversion-plans, "Shape 4 step 1 shipped 2026-08-22".
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:45 UTC
Signed with PGP, not checked
Commit: bd7080543035e6e2fa80f363e49534254544027c
Parent: 9b74828
14 files changed, +942 insertions, -222 deletions
M server/Cargo.lock +20 -20
@@ -10740,26 +10740,6 @@
10740 10740 "pkg-config",
10741 10741 ]
10742 10742
10743 - [[patch.unused]]
10744 - name = "kberg"
10745 - version = "0.1.0"
10746 -
10747 - [[patch.unused]]
10748 - name = "ops-status"
10749 - version = "0.1.0"
10750 -
10751 - [[patch.unused]]
10752 - name = "painhours"
10753 - version = "0.1.0"
10754 -
10755 - [[patch.unused]]
10756 - name = "synckit-client"
10757 - version = "0.8.1"
10758 -
10759 - [[patch.unused]]
10760 - name = "synckit-config"
10761 - version = "0.2.0"
10762 -
10763 10743 [[patch.unused]]
10764 10744 name = "quasi-immediate"
10765 10745 version = "0.54.0"
@@ -10775,3 +10755,23 @@
10775 10755 [[patch.unused]]
10776 10756 name = "quasi-tauri"
10777 10757 version = "0.54.0"
10758 +
10759 + [[patch.unused]]
10760 + name = "synckit-client"
10761 + version = "0.8.1"
10762 +
10763 + [[patch.unused]]
10764 + name = "synckit-config"
10765 + version = "0.2.0"
10766 +
10767 + [[patch.unused]]
10768 + name = "kberg"
10769 + version = "0.1.0"
10770 +
10771 + [[patch.unused]]
10772 + name = "ops-status"
10773 + version = "0.1.0"
10774 +
10775 + [[patch.unused]]
10776 + name = "painhours"
10777 + version = "0.1.0"
@@ -9020,11 +9020,6 @@
9020 9020 font-size: var(--text-body);
9021 9021 }
9022 9022
9023 - .insert-image-btn {
9024 - margin-top: var(--gap-peer);
9025 - font-size: var(--text-note);
9026 - }
9027 -
9028 9023 .section-edit-modal {
9029 9024 margin-top: var(--gap-section);
9030 9025 padding: var(--gap-section);
@@ -10686,10 +10681,6 @@
10686 10681 resize: vertical;
10687 10682 }
10688 10683 .blog-editor-slug-spinner { font-size: var(--text-note); }
10689 - .blog-editor-media-btn {
10690 - margin-top: var(--gap-peer);
10691 - font-size: var(--text-note);
10692 - }
10693 10684 .blog-editor-actions {
10694 10685 display: flex;
10695 10686 gap: var(--gap-section);
@@ -11145,11 +11136,6 @@
11145 11136 ITEM TEXT EDITOR (partials/item_text_editor.html)
11146 11137 =========================================== */
11147 11138
11148 - .text-editor-insert-btn {
11149 - margin-top: var(--gap-peer);
11150 - font-size: var(--text-note);
11151 - }
11152 -
11153 11139 .text-editor-actions {
11154 11140 margin-top: var(--gap-section);
11155 11141 }
@@ -12057,3 +12043,81 @@
12057 12043 color: revert-layer;
12058 12044 }
12059 12045 }
12046 +
12047 + /* ===========================================
12048 + DESCRIBED MODAL (quasi RegionKind::Modal)
12049 + ===========================================
12050 + The first described modal on this host is the media picker; these two rules
12051 + are what "modal" means here, and every one after it gets them for free. The
12052 + description says the region IS a modal and says nothing about where it sits,
12053 + which is the division the vocabulary draws: `role="dialog" aria-modal="true"`
12054 + comes off the renderer, and a dialog that lays out in flow would make that
12055 + attribute a lie.
12056 +
12057 + Two boxes because a scrim and a panel are two boxes: the modal region covers
12058 + the viewport and takes the presses the page behind it must not get, and the
12059 + region inside it is the panel. */
12060 + .region.modal {
12061 + position: fixed;
12062 + inset: 0;
12063 + z-index: var(--z-overlay);
12064 + display: flex;
12065 + align-items: center;
12066 + justify-content: center;
12067 + background: var(--overlay);
12068 + }
12069 +
12070 + .region.modal > .region {
12071 + position: relative;
12072 + z-index: var(--z-modal);
12073 + width: 90%;
12074 + max-width: 700px;
12075 + max-height: 80vh;
12076 + overflow-y: auto;
12077 + padding: var(--gap-section);
12078 + background: var(--surface-overlay);
12079 + box-shadow: var(--elevation-overlay);
12080 + }
12081 +
12082 + /* ===========================================
12083 + MEDIA PICKER (crate::quasi::media_picker)
12084 + ===========================================
12085 + Addressed by the widget names the description carries, not by ids: a document
12086 + holds one picker per markdown editor and their ids differ by destination,
12087 + while what they are drawn like is the same assembly every time. */
12088 + [data-widget="media-picker-grid"] {
12089 + display: grid;
12090 + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
12091 + gap: var(--gap-peer);
12092 + margin-top: var(--gap-section);
12093 + }
12094 +
12095 + [data-widget="media-tile"] {
12096 + display: flex;
12097 + flex-direction: column;
12098 + gap: var(--gap-peer);
12099 + padding: var(--gap-peer);
12100 + border: 1px solid var(--border);
12101 + background: var(--surface-raised);
12102 + min-width: 0;
12103 + }
12104 +
12105 + /* A cap, not a height, and never a bare `img`. The design system already sets
12106 + the picture's width, height and background, and an app rule taking any of
12107 + those wins over `@layer makeover` silently — which is what `makeover-build`'s
12108 + drift check refuses, and it is right to. `max-height` is not one of them, and
12109 + it is all this needs: `Fit::Contain` is in the description, so a capped
12110 + picture letterboxes inside the cap rather than being cropped or stretched. */
12111 + [data-widget="media-tile"] .picture-img {
12112 + max-height: 80px;
12113 + }
12114 +
12115 + /* The file name is the control, so it wraps rather than pushing the tile wide.
12116 + `Act` is a label and nothing in the vocabulary says a control reads as a
12117 + picture, so the thumbnail above it is a sibling and only this half answers a
12118 + press. Filed rather than faked; see the module header. */
12119 + [data-widget="media-tile"] .button {
12120 + overflow-wrap: anywhere;
12121 + text-align: left;
12122 + font-size: var(--text-note);
12123 + }
@@ -62,7 +62,13 @@
62 62 /// left and is deleted. The wrapper that survived every earlier batch because
63 63 /// it was reference-counted by four unconverted screens went without waiting
64 64 /// for any of them, which is what the glue-module ruling (`27d5e5b8`) is for.
65 - const HIGH_WATER: usize = 113;
65 + ///
66 + /// 111 on 2026-08-22, and also progress: Shape 4 step 1 (`6fb46f7c`) deleted
67 + /// `static/media-picker.js` whole, taking `window.mediaPickerOpen` and
68 + /// `window._mediaPickerSelect` with it. That step waited eleven days on a
69 + /// vocabulary gap -- nothing could say "put this chosen value into that other
70 + /// field" -- and shipped the day `Act::fills` did.
71 + const HIGH_WATER: usize = 111;
66 72
67 73 /// Every file whose global assignments count: the legacy scripts and the typed
68 74 /// modules that replaced them.
@@ -109,9 +109,16 @@
109 109 // An embed calls no route, so it takes no transport and no scripts.
110 110 // A document that does ask fails visibly on the first control pressed,
111 111 // and nothing here asks: every control is an external link.
112 + //
113 + // Four `without_` calls rather than three since quasi 0.54.0, which
114 + // added the fill script. It is independent of htmx by design — a
115 + // deposit is two attributes on a control — so dropping the transport
116 + // does not drop it, and an embed that names no destination field has
117 + // nothing for it to read.
112 118 .without_htmx()
113 119 .without_hyperscript()
114 120 .without_clock()
121 + .without_fill()
115 122 .with_chrome(Chrome::new())
116 123 .with_head_first(head_first());
117 124 shell.body_class = body_class.map(str::to_owned);
@@ -276,6 +283,7 @@
276 283 .without_htmx()
277 284 .without_hyperscript()
278 285 .without_clock()
286 + .without_fill()
279 287 .with_chrome(Chrome::new())
280 288 .with_head_first(format!("{}<style>{PLAYER_CSS}</style>", head_first()));
281 289 shell.body_class = Some("embed-player".to_owned());
@@ -40,6 +40,7 @@
40 40 pub mod item_tabs;
41 41 pub mod library_contacts;
42 42 pub mod library_tabs;
43 + pub mod media_picker;
43 44 pub mod project_content;
44 45 pub mod project_tabs;
45 46 pub mod rich_field;
@@ -21,11 +21,14 @@
21 21 //! prefix, which is what makes it unique in a document holding two of them.
22 22 //!
23 23 //! That is not a coincidence being leaned on. It is what keeps the conversion
24 - //! from breaking the two things that reach these controls by id from outside:
25 - //! `media-picker.js` takes a textarea id as an argument (`text-body` and
26 - //! `post-body` are written into the templates as `data-arg`), and each
27 - //! surface's own script reads its value back with `getElementById`. Both keep
28 - //! working because the id is the same string it was.
24 + //! from breaking the thing that reaches these controls by id from outside: each
25 + //! surface's own script reads its value back with `getElementById`, and the id
26 + //! is the same string it was.
27 + //!
28 + //! The other reader used to be `media-picker.js`, which took a textarea id as an
29 + //! argument. It is gone (`f35aafee`, 2026-08-22): the described picker addresses
30 + //! its destination by [`Field::name`], which is what made [`named`] necessary —
31 + //! see its own note.
29 32 //!
30 33 //! # Why height is the app's and not the description's
31 34 //!
@@ -81,7 +84,41 @@
81 84 /// host prefixes with nothing and a description that has no idea it exists.
82 85 #[must_use]
83 86 pub fn html(prefix: &str, label: &str, placeholder: &str, value: &str, height: Height) -> String {
84 - let mut field = Field::new(FieldKind::Rich, "body", label);
87 + named(Some(prefix), "body", label, placeholder, value, height)
88 + }
89 +
90 + /// The same editor, naming the value itself.
91 + ///
92 + /// `f35aafee`, 2026-08-22. The rule above — "the name is what the value is, and
93 + /// the id is what tells two editors apart" — held while nothing had to *address*
94 + /// an editor. [`Act::fills`](quasi_router::Act::fills) does: a picker names the
95 + /// box it deposits into by [`Field::name`], because a name is what identifies a
96 + /// field within a description.
97 + ///
98 + /// So a document holding two editors under one name has one addressable editor
99 + /// and one unreachable one. `item_details.html` is the measured case and was
100 + /// worse than that: its two section bodies were raw `<textarea>` elements with
101 + /// an id and **no name at all**, which nothing can address. They come through
102 + /// here with names of their own, `new-sec-body` and `edit-sec-body`, and no
103 + /// prefix, so the ids their scripts read by are the strings they always were.
104 + ///
105 + /// The four callers of [`html`] are unaffected and stay named `body`: each is
106 + /// the only markdown editor under that name in its document, which is what
107 + /// makes them addressable.
108 + ///
109 + /// Passing `None` for the prefix is what makes the name the whole id, and is
110 + /// the only way to give an editor a document-unique name without also changing
111 + /// the id something reads it by.
112 + #[must_use]
113 + pub fn named(
114 + prefix: Option<&str>,
115 + name: &str,
116 + label: &str,
117 + placeholder: &str,
118 + value: &str,
119 + height: Height,
120 + ) -> String {
121 + let mut field = Field::new(FieldKind::Rich, name, label);
85 122 field.placeholder = Some(placeholder);
86 123
87 124 let filling = Filling {
@@ -91,7 +128,7 @@
91 128 // fact the description does not carry, and quasi's suggestion source is
92 129 // the one caller there is.
93 130 control_attrs: None,
94 - id_prefix: Some(prefix),
131 + id_prefix: prefix,
95 132 };
96 133
97 134 format!(
@@ -105,10 +142,9 @@
105 142 mod tests {
106 143 use super::Height;
107 144
108 - /// The id every caller depends on. `media-picker.js` is handed `text-body`
109 - /// as a literal and three scripts read their value back by id, so the
110 - /// prefix-plus-`body` arrangement is the contract and not an implementation
111 - /// detail.
145 + /// The id every caller depends on. Three scripts read their value back by
146 + /// id, so the prefix-plus-`body` arrangement is the contract and not an
147 + /// implementation detail.
112 148 #[test]
113 149 fn the_prefix_and_the_name_make_the_id_the_templates_already_had() {
114 150 for prefix in ["text", "post", "new-psec", "edit-psec", "new-section"] {
@@ -118,9 +154,9 @@
118 154 }
119 155 }
120 156
121 - /// The name is the same on all five, because it is what the value is. Only
122 - /// the id distinguishes two editors in one document, which is what
123 - /// `Filling::id_prefix` exists to do.
157 + /// The name is the same on all four callers of `html`, because it is what
158 + /// the value is. Only the id distinguishes two editors in one document,
159 + /// which is what `Filling::id_prefix` exists to do.
124 160 #[test]
125 161 fn the_name_is_the_value_and_never_the_surface() {
126 162 let html = super::html("new-psec", "Body", "", "", Height::Standard);
@@ -128,6 +164,19 @@
128 164 assert!(!html.contains("name=\"new-psec-body\""), "{html}");
129 165 }
130 166
167 + /// And the exception the picker needs: an editor that has to be addressed
168 + /// by name in a document that holds another one carries a name of its own,
169 + /// with the id its scripts read by unchanged.
170 + #[test]
171 + fn a_named_editor_keeps_the_id_and_gains_a_name_that_tells_it_apart() {
172 + for name in ["new-sec-body", "edit-sec-body"] {
173 + let html = super::named(None, name, "Body (Markdown)", "", "", Height::Compact);
174 + assert!(html.contains(&format!("id=\"{name}\"")), "{html}");
175 + assert!(html.contains(&format!("name=\"{name}\"")), "{html}");
176 + assert!(html.contains(&format!("for=\"{name}\"")), "{html}");
177 + }
178 + }
179 +
131 180 /// The mark and the chrome the binder looks for. Without all three
132 181 /// `markdown-editor.js` finds nothing and the reader is left with the plain
133 182 /// textarea, which is the no-script rendering rather than a failure, so
@@ -35,10 +35,10 @@
35 35 <span id="blog-slug-status"></span>
36 36 </div>
37 37 {# The described markdown field. Keeps the id `post-body`, which is
38 - what the Insert Image button hands the media picker and what
39 - `blog-editor.js` reads on save and on autosave. #}
38 + what `blog-editor.js` reads on save and on autosave; the picker
39 + below addresses it by NAME (`body`). #}
40 40 {{ crate::quasi::rich_field::html("post", "Content (Markdown)", "Write your post in Markdown...", post_body, crate::quasi::rich_field::Height::Tall)|safe }}
41 - <button type="button" class="btn-secondary blog-editor-media-btn" data-action="mediaPickerOpen" data-arg="post-body">Insert Image</button>
41 + {{ crate::quasi::media_picker::trigger("body")|safe }}
42 42 {% if is_changelog_project %}
43 43 <div class="form-group blog-editor-landing-toggle">
44 44 <label for="post-show-on-landing">
@@ -62,6 +62,5 @@
62 62 {% endblock %}
63 63
64 64 {% block scripts %}
65 - <script src="/static/media-picker.js"></script>
66 65 <script src="/static/blog-editor.js"></script>
67 66 {% endblock %}
@@ -43,7 +43,6 @@
43 43 {% endblock %}
44 44
45 45 {% block scripts %}
46 - <script src="/static/media-picker.js?v=0518"></script>
47 46 <script src="/static/item-details.js?v=0518"></script>
48 47 <script src="/static/item-upload.js?v=0820"></script>
49 48 {% endblock %}
@@ -1,11 +1,12 @@
1 1 {# The Write/Preview pair, the textarea and the preview pane are the described
2 2 markdown field's, not this template's: see `crate::quasi::rich_field`. The
3 - control keeps the id `text-body`, which is what the Insert Image button below
4 - passes to the media picker and what `partial-item-text-editor.js` reads. #}
3 + control keeps the id `text-body`, which is what `partial-item-text-editor.js`
4 + reads; the picker below addresses it by NAME (`body`), which is the whole of
5 + what `Act::fills` says. See `crate::quasi::media_picker`. #}
5 6 <div class="text-editor" id="text-editor">
6 7 {{ crate::quasi::rich_field::html("text", "Content", "Write your content in Markdown...", body.as_deref().unwrap_or(""), crate::quasi::rich_field::Height::Tall)|safe }}
7 8
8 - <button type="button" class="btn-secondary text-editor-insert-btn" data-action="mediaPickerOpen" data-arg="text-body">Insert Image</button>
9 + {{ crate::quasi::media_picker::trigger("body")|safe }}
9 10 <div class="editor-footer">
10 11 <span class="word-count" id="word-count">{{ word_count.unwrap_or(0) }} words</span>
11 12 <span class="reading-time">{{ reading_time_minutes.unwrap_or(1) }} min read</span>