max / makenotwork
- Wiki
- mnw-shape-conversion-plans, "Shape 4 step 1 shipped 2026-08-22".
14 files changed,
+942 insertions,
-222 deletions
| @@ -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> |