Transforming HTML clipboard contents with Emacs to smooth out Mailchimp annoyances: dates, images, comments, colours

| emacs

I've recently started handling the Bike Brigade newsletter, so now I'm itching to solve the little bits of friction that get in my way when I work with the rich-text Mailchimp block editor.

I'm not quite ready to generate everything with Org Mode. Sometimes other people go in and edit the newsletter through the web interface, so I shouldn't just dump a bunch of HTML in. (We don't have the more expensive plan that would allow me to make editable templates.) I draft the newsletter as a Slack canvas so more people can weigh in with their suggestions:

2025-06-20_20-58-49.png
Figure 1: Screenshot of Slack canvas

And then I redo it in Mailchimp:

2025-06-20_21-01-08.png
Figure 2: Screenshot of Mailchimp design

My process is roughly:

  1. Duplicate blocks.
  2. Copy the text for each item and paste it in. Adjust formatting.
  3. Update the dates and links. Flip back and forth between the dispatch webpage and Mailchimp, getting the links and the dates just right.
  4. Download images one by one.
  5. Replace the images by uploading the saved images. Hunt through lots of files named image (3).png, image (4).png, and so on. Update their attributes and links.
  6. Change text and link colours as needed by manually selecting the text, clicking on the colour button in the toolbar, and selecting the correct colour.
  7. Change the text on each button. Switch to Slack, copy the link, switch back to Mailchimp, and update the link.

I think I can get Emacs to make things easier.

Automating buttons

The newsletter includes a button to make it easier to volunteer for deliveries. In case people want to plan ahead, I also include a link to the following week's signups.

Dates are fiddly and error-prone, so I want to automate them. I can use a Mailchimp code block to paste in some HTML directly, since I don't think other people will need to edit this button. Here I take advantage of org-read-date's clever date parsing so that I can specify dates like +2Sun to mean two Sundays from now. That way, I don't have to do any date calculations myself.

This code generates something like this:

2025-06-20_21-09-44.png
Figure 3: Screenshot of buttons
Text from the screenshot

SIGN UP NOW TO DELIVER JUN 23-29
You can also sign up early to deliver Jun 30-Jul 6

Here's the code. It calculates the dates, formats the HTML code. I use format-time-string to format just the month part of the dates and compare them to tell if I can skip the month part of the end date. After the HTML is formatted, the code uses xdotool (a Linux command-line tool) to switch to Google Chrome so that I can paste it in.

(defun my-brigade-copy-signup-block ()
  (interactive)
  (let* ((newsletter-date (org-read-date nil nil "+Sun"))
         (current-week (org-read-date nil t "+Mon"))
         (current-week-end (org-read-date nil t "+2Sun"))
         (next-week (org-read-date nil t "+2Mon"))
         (next-week-end (org-read-date nil t "+3Sun")))
    (kill-new
     (format
      "<div style=\"background-color: #223f4d; text-align: center; max-width: 384px; margin: auto; margin-bottom: 12px;\"><a href=\"https://dispatch.bikebrigade.ca/campaigns/signup?current_week=%s\" target=\"_blank\" class=\"mceButtonLink\" style=\"background-color:#223f4d;border-radius:0;border:2px solid #223f4d;color:#ffffff;display:block;font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;font-size:16px;font-weight:normal;font-style:normal;padding:16px 28px;text-decoration:none;text-align:center;direction:ltr;letter-spacing:0px\" rel=\"noreferrer\">SIGN UP NOW TO DELIVER %s-%s</a>
</div>
<p style=\"text-align: center; font-family: 'Helvetica Neue', Helvetica, Arial, Verdana\"><a href=\"https://dispatch.bikebrigade.ca/campaigns/signup?current_week=%s\" style=\"color: #476584; margin-top: 12px; margin-bottom: 12px;\" target=\"_blank\">You can also sign up early to deliver %s-%s</a></p>"
      (format-time-string "%Y-%m-%d" current-week)
      (upcase (format-time-string "%b %e" current-week))
      (format-time-string
       (if (string= (format-time-string "%m" current-week)
                    (format-time-string "%m" current-week-end))
           "%-e"
         "%b %-e")
       current-week-end)
      (format-time-string "%Y-%m-%d" next-week)
      (format-time-string "%b %e" next-week)
      (format-time-string
       (if (string= (format-time-string "%m" next-week)
                    (format-time-string "%m" next-week-end))
           "%-e"
         "%b %-e")
       next-week-end)))
    (shell-command "xdotool search  --onlyvisible --all Chrome windowactivate windowfocus")))

Now I can use an Org Mode link like elisp:my-brigade-copy-signup-block to generate the HTML code that I can paste into a Mailchimp code block. The button link is underlined even though the inline style says text-decoration:none, but it's easy enough to remove that with Ctrl+u.

Transforming HTML

The rest of the newsletter is less straightforward. I copy parts of the newsletter draft from the canvas in Slack to the block editor in Mailchimp. When I paste it in, I need to do a lot to format the results neatly.

I think I'll want to use this technique of transforming HTML data on the clipboard again in the future, so let's start with a general way to do it. This uses the xclip tool for command-line copying and pasting in X11 environments. It parses the HTML into a document object model (DOM), runs it through various functions sequentially, and copies the transformed results. Using DOMs instead of regular expressions means that it's easier to handle nested elements.

(defvar my-transform-html-clipboard-functions nil "List of functions to call with the clipboard contents.
Each function should take a DOM node and return the resulting DOM node.")
(defun my-transform-html-clipboard (&optional activate-app-afterwards functions text)
  "Parse clipboard contents and transform it.
This calls FUNCTIONS, defaulting to `my-transform-html-clipboard-functions'.
If ACTIVATE-APP-AFTERWARDS is non-nil, use xdotool to try to activate that app's window."
  (with-temp-buffer
    (let ((text (or text (shell-command-to-string "unbuffer -p xclip -o -selection clipboard -t text/html 2>& /dev/null"))))
      (if (string= text "")
          (error "Clipboard does not contain HTML.")
        (insert (concat "<div>"
                        text
                        "</div>"))))
    (let ((dom (libxml-parse-html-region (point-min) (point-max))))
      (erase-buffer)
      (dom-print (seq-reduce
                  (lambda (prev val)
                    (funcall val prev))
                  (or functions my-transform-html-clipboard-functions)
                  dom)))
    (shell-command-on-region
     (point-min) (point-max)
     "xclip -i -selection clipboard -t text/html -filter 2>& /dev/null"))
    (when activate-app-afterwards
      (call-process "xdotool" nil nil nil "search" "--onlyvisible" "--all" activate-app-afterwards "windowactivate" "windowfocus")))

Saving images

Images from Slack don't transfer cleanly to Mailchimp. I can download images from Slack one at a time, but Slack saves them with generic filenames like image (2).png. Each main newsletter item has one image, so I'd like to automatically save the image using the item title.

When I copy HTML from the Slack canvas, images are included as data URIs. The markup looks like this: <img src='data:image/png;base64,iVBORw0KGgo... With the way I do the draft in Slack, images are always followed by the item title as an h2 heading. If there isn't a heading, the image just doesn't get saved. If there's no image in a section, the code clears the variable, so that's fine too. I can parse and save the images like this:

(defun my-transform-html-save-images (dom dir &optional file-prefix transform-fn)
  (let (last-image)
    (dom-search dom
                (lambda (node)
                  (pcase (dom-tag node)
                    ('img
                     (let ((data (dom-attr node 'src)))
                       (with-temp-buffer
                         (insert data)
                         (goto-char (point-min))
                         (when (looking-at "data:image/\\([^;]+?\\);base64,")
                           (setq last-image (cons (match-string 1)
                                                  (buffer-substring (match-end 0) (point-max))))))))
                    ('h2
                     (when last-image
                       (with-temp-file
                           (expand-file-name
                            (format "%s%s.%s"
                                    (or file-prefix "")
                                    (if transform-fn
                                        (funcall transform-fn (dom-texts node))
                                      (dom-texts node))
                                    (car last-image))
                            dir)
                         (set-buffer-file-coding-system 'binary)
                         (insert (base64-decode-string (cdr last-image)))))
                     (setq last-image nil)))))
    dom))

I wrapped this in a small function for newsletter-specific processing:

(defvar my-brigade-newsletter-images-directory "~/proj/bike-brigade/newsletter/images")
(defun my-brigade-save-newsletter-images (dom)
  (my-transform-html-save-images
   dom
   my-brigade-newsletter-images-directory
   (concat (org-read-date nil nil "+Sun")
           "-news-")
   (lambda (heading)
     (replace-regexp-in-string
      "[^-a-z0-9]" ""
      (replace-regexp-in-string
       " +"
       "-"
       (string-trim (downcase heading)))))))

For easier testing, I used xclip -o -selection clipboard -t text/html > ~/Downloads/test.html to save the clipboard. To run the code with the saved clipboard, I can call it like this:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))

Cleaning up

Now that I've saved the images, I can remove them:

(defun my-transform-html-remove-images (dom)
  (dolist (img (dom-by-tag dom 'img))
    (dom-remove-node dom img))
  dom)

I can also remove the italics that I use for comments.

(defun my-transform-html-remove-italics (dom)
  (dolist (node (dom-by-tag dom 'i))
    (dom-remove-node dom node))
  dom)

Here's how I can test it:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images
   my-transform-html-remove-images
   my-transform-html-remove-italics)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))

Removing sections

I put longer comments and instructions under "Meta" headings, which I can automatically remove.

(defvar my-brigade-section nil)
(defun my-brigade-remove-meta-recursively (node &optional recursing)
  "Remove <h1>Meta</h1> headings in NODE and the elements that follow them.
Resume at the next h1 heading."
  (unless recursing (setq my-brigade-section nil))
  (cond
   ((eq (dom-tag node) 'h1)
    (setq my-brigade-section (string-trim (dom-texts node)))
    (if (string= my-brigade-section "Meta")
        nil
      node))
   ((string= my-brigade-section "Meta")
    nil)
   (t
    (let ((processed
           (seq-keep
            (lambda (child)
              (if (stringp child)
                  (unless (string= my-brigade-section "Meta")
                    child)
                (my-brigade-remove-meta-recursively child t)))
            (dom-children node))))
      `(,(dom-tag node) ,(dom-attributes node) ,@processed)))))

Let's try it out:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images
   my-transform-html-remove-images
   my-transform-html-remove-italics
   my-brigade-remove-meta-recursively)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))

Formatting calls to action

Mailchimp recommends using buttons for calls to action so that they're larger and easier to click than links. In my Slack canvas draft, I use [ link text ] to indicate those calls to action. Wouldn't it be nice if my code automatically transformed those into centered buttons?

(defun my-brigade-format-buttons (dom)
  (dolist (node (dom-by-tag dom 'a))
    (let ((text (dom-texts node)))
      (if (string-match "\\[ *\\(.+?\\) *\\]" text)
          ;; button, wrap in a table
          (with-temp-buffer
            (insert
             (format "<table align=\"center\" border=\"0\" cellpadding=\"0\" cellspacing=\"0\" role=\"presentation\" data-block-id=\"627\" class=\"mceButtonContainer\" style=\"margin: auto; text-align: center\"><tbody><tr class=\"mceStandardButton\"><td style=\"background-color:#000000;border-radius:0;text-align:center\" valign=\"top\" class=\"mceButton\"><a href=\"%s\" target=\"_blank\" class=\"mceButtonLink\" style=\"background-color:#000000;border-radius:0;border:2px solid #000000;color:#ffffff;display:block;font-family:'Helvetica Neue', Helvetica, Arial, Verdana, sans-serif;font-size:16px;font-weight:normal;font-style:normal;padding:16px 28px;text-decoration:none;text-align:center;direction:ltr;letter-spacing:0px\" rel=\"noreferrer\">%s</a></td></tr></tbody></table>"
                     (dom-attr node 'href)
                     (match-string 1 text)))
            (dom-add-child-before
             (dom-parent dom node)
             (car (dom-by-tag (libxml-parse-html-region (point-min) (point-max)) 'table)) node)
            (dom-remove-node dom node)))))
  dom)

Now I can test those functions in combination:

(my-transform-html-clipboard
 nil
 '(my-brigade-save-newsletter-images
   my-transform-html-remove-images
   my-transform-html-remove-italics
   my-brigade-remove-meta-recursively
   my-brigade-format-buttons)
 (with-temp-buffer (insert-file-contents "~/Downloads/test.html") (buffer-string)))
2025-06-20_21-10-38.png
Figure 4: Screenshot of button

Wrapping it up

Now that I've made all those little pieces, I can put them together in two interactive functions. The first function will be for the regular colour scheme, and the second function will be for the light-on-dark colour scheme. For convenience, I'll have it activate Google Chrome afterwards so that I can paste the results into the right block.

(defun my-brigade-transform-html (&optional recolor file)
  (interactive (list (when current-prefix-arg (read-file-name "File: "))))
  (my-transform-html-clipboard
  "Chrome"
  (append
   '(my-brigade-save-newsletter-images
     my-transform-html-remove-images
     my-transform-html-remove-italics
     my-brigade-remove-meta-recursively
     my-brigade-format-buttons)
   (if recolor '(my-brigade-recolor-recursively)))
  (when file
    (with-temp-buffer (insert-file-contents file) (buffer-string)))))

(defun my-brigade-transform-community-html (&optional file)
  (interactive (list (when current-prefix-arg (read-file-name "File: "))))
  (my-brigade-transform-html t file))

And then I can use links like this for quick shortcuts:

  • [[elisp:(my-brigade-transform-html nil "~/Downloads/test.html")]]
  • [[elisp:(my-brigade-transform-community-html "~/Downloads/test.html")]]
  • [[elisp:(my-brigade-transform-html)]]

Since this pastes the results as formatted text, it's editable using the usual Mailchimp workflow. That way, other people can make last-minute updates.

With embedded images, the saved HTML is about 8 MB. The code makes quick work of it. This saves about 10-15 minutes per newsletter, so the time investment probably won't directly pay off. But it also reduces annoyance, which is even more important than raw time savings. I enjoyed figuring all this out. I think this technique of transforming HTML in the clipboard will come in handy. By writing the functions as small, composable parts, I can change how I want to transform the clipboard.

Next steps

It would be interesting to someday automate the campaign blocks while still making them mostly editable, as in the following examples:

Maybe someday!

(Also, hat tip to this Reddit post that helped me get xclip to work more reliably from within Emacs by adding -filter 2>& /dev/null to the end of my xclip call so it didn't hang.)

View org source for this post

Run source blocks in an Org Mode subtree by custom ID

| emacs, org

I like the way Org Mode lets me logically group functions into headings. If I give the heading a CUSTOM_ID property (which is also handy for exporting to HTML, as it turns into an link anchor), I can use that property to find the subtree. Then I can use org-babel-execute-subtree to execute all source blocks in that subtree, which means I can mix scripting languages if I want to.

Here's the code:

(defun my-org-execute-subtree-by-custom-id (id &optional filename)
  "Prompt for a CUSTOM_ID value and execute the subtree with that ID.
If called with \\[universal-argument], prompt for a file, and then prompt for the ID."
  (interactive (if current-prefix-arg
                   (let ((file (read-file-name "Filename: ")))
                     (list
                      (with-current-buffer (find-file-noselect file)
                        (completing-read
                         "Custom ID: "
                         (org-property-values "CUSTOM_ID")))
                      file))
                 (list
                  (completing-read "Custom ID: " (org-property-values "CUSTOM_ID")))))
  (with-current-buffer (if filename (find-file-noselect filename) (current-buffer))
    (let ((pos (org-find-property "CUSTOM_ID" id)))
      (if pos
          (org-babel-execute-subtree)
        (if filename(error "Could not find %s in %s" id filename)
          (error "Could not find %s" id))))))

For example, in Using Org Mode, Emacs Lisp, and TRAMP to parse meetup calendar entries and generate a crontab, I have a Emacs Lisp source block that generates a crontab on a different computer, and a shell source block that installs it on that computer.

Technical notes: org-babel-execute-subtree narrows to the current subtree, so if I want anything from the rest of the buffer, I need to widen the focus again. Also, it's wrapped in a save-restriction and a save-excursion, so someday I might want to figure out how to handle the cases where I want to change what I'm looking at.

elisp: links in Org Mode let me call functions by clicking on them or following them with C-c C-o (org-open-at-point). This means I can make links that execute subtrees that might even be in a different file. For example, I can define links like these:

  • [[elisp:(my-org-execute-subtree-by-custom-id "update" "~/sync/emacs-calendar/README.org")][Update Emacs calendar]]
  • [[elisp:(my-org-execute-subtree-by-custom-id "crontab" "~/sync/emacs-calendar/README.org")][Update Emacs meetup crontab]]

That could be a good starting point for a dashboard.

Related: Execute a single named Org Babel source block

This is part of my Emacs configuration.
View org source for this post

Using Org Mode, Emacs Lisp, and TRAMP to parse meetup calendar entries and generate a crontab

| org, emacs

Times and time zones trip me up. Even with calendar notifications, I still fumble scheduled events. Automation helps me avoid embarrassing hiccups.

We run BigBlueButton as a self-hosted web conferencing server for EmacsConf. It needs at least 8 GB of RAM when active. When it's dormant, it fits on a 1 GB RAM virtual private server. It's easy enough to scale the server up and down as needed. Using the server for Emacs meetups in between EmacsConfs gives people a way to get together, and it also means I can regularly test the infrastructure. That makes scaling it up for EmacsConf less nerve-wracking.

I have some code that processes various Emacs meetup iCalendar files (often with repeating entries) and combines them into one iCal file that people can subscribe to calendar, as well as Org files in different timezones that they can include in their org-agenda-files. The code I use to parse the iCal seems to handle time zones and daylight savings time just fine. I set it up so that the Org files have simple non-repeating entries, which makes them easy to parse. I can use the Org file to determine the scheduled jobs to run with cron on a home server (named xu4) that's up all the time.

This code parses the Org file for schedule information, then generates pairs of crontab entries. The first entry scales the BigBlueButton server up 1 hour before the event using my bbb-testing script, and the second entry scales the server down 6 hours after the event using my bbb-dormant script (more info). That gives organizers time to test it before the event starts, and it gives people plenty of time to chat. A shared CPU 8 GB RAM Linode costs USD 0.072 per hour, so that's USD 0.50 per meetup hosted.

Using #+begin_src emacs-lisp :file "/ssh:xu4:~/bbb.crontab" :results file as the header for my code block and using an SSH agent for authentication lets me use TRAMP to write the file directly to the server. (See Results of Evaluation (The Org Manual))

(let* ((file "/home/sacha/sync/emacs-calendar/emacs-calendar-toronto.org")
       (time-format "%M %H %d %m")
       (bbb-meetups "OrgMeetup\\|Emacs Berlin\\|Emacs APAC")
       (scale-up "/home/sacha/bin/bbb-testing")
       (scale-down "/home/sacha/bin/bbb-dormant"))
  (mapconcat
   (lambda (o)
     (let ((start-time (format-time-string time-format (- (car o) 3600 )))
           (end-time (format-time-string time-format (+ (car o) (* 6 3600)))))
       (format "# %s\n%s * %s\n%s * %s\n"
               (cdr o)
               start-time
               scale-up
               end-time
               scale-down)))
   (delq nil
         (with-temp-buffer
           (insert-file-contents file)
           (org-mode)
           (goto-char (point-min))
           (org-map-entries
            (lambda ()
              (when (and
                     (string-match bbb-meetups (org-entry-get (point) "ITEM"))
                     (re-search-forward org-tr-regexp (save-excursion (org-end-of-subtree)) t))
                (let ((time (match-string 0)))
                  (cons (org-time-string-to-seconds time)
                        (format "%s - %s" (org-entry-get (point) "ITEM") time)))))
            "LEVEL=1")))
   "\n"))

The code makes entries that look like this:

# OrgMeetup (virtual) - <2025-06-11 Wed 12:00>--<2025-06-11 Wed 14:00>
00 11 11 06 * /home/sacha/bin/bbb-testing
00 18 11 06 * /home/sacha/bin/bbb-dormant

# Emacs Berlin (hybrid, in English) - <2025-06-25 Wed 12:30>--<2025-06-25 Wed 14:30>
30 11 25 06 * /home/sacha/bin/bbb-testing
30 18 25 06 * /home/sacha/bin/bbb-dormant

# Emacs APAC: Emacs APAC meetup (virtual) - <2025-06-28 Sat 04:30>--<2025-06-28 Sat 06:00>
30 03 28 06 * /home/sacha/bin/bbb-testing
30 10 28 06 * /home/sacha/bin/bbb-dormant

This works because meetups don't currently overlap. If there were, I'll need to tweak the code so that the server isn't downscaled in the middle of a meetup. It'll be a good problem to have.

I need to load the crontab entries by using crontab bbb.crontab. Again, I can tell Org Mode to run this on the xu4 home server. This time I use the :dir argument to specify the default directory, like this:

#+begin_src sh :dir "/ssh:xu4:~" :results silent
crontab bbb.crontab
#+end_src

Then cron can take care of things automatically, and I'll just get the e-mail notifications from Linode telling me that the server has been resized. This has already come in handy, like when I thought of Emacs APAC as being on Saturday, but it was actually on Friday my time.

I have another Emacs Lisp block that I use to retrieve all the info and update the list of meetups. I can add (goto-char (org-find-property "CUSTOM_ID" "crontab")) to find this section and use org-babel-execute-subtree to execute all the code blocks. That makes it an automatic part of my process for updating the Emacs Calendar and Emacs News. Here's the code that does the calendar part (Org source):

(defun my-prepare-calendar-for-export ()
  (interactive)
  (with-current-buffer (find-file-noselect "~/sync/emacs-calendar/README.org")
    (save-restriction
      (widen)
      (goto-char (point-min))
      (re-search-forward "#\\+NAME: event-summary")
      (org-ctrl-c-ctrl-c)
      (org-export-to-file 'html "README.html")
      ;; (unless my-laptop-p (my-schedule-announcements-for-upcoming-emacs-meetups))
      ;; update the crontab
      (goto-char (org-find-property "CUSTOM_ID" "crontab"))
      (org-babel-execute-subtree)
      (when my-laptop-p
        (org-babel-goto-named-result "event-summary")
        (re-search-forward "^- ")
        (goto-char (match-beginning 0))
        (let ((events (org-babel-read-result)))
          (oddmuse-edit "EmacsWiki" "Usergroups")
          (goto-char (point-min))
          (delete-region (progn (re-search-forward "== Upcoming events ==\n\n") (match-end 0))
                         (progn (re-search-forward "^$") (match-beginning 0)))
          (save-excursion (insert (mapconcat (lambda (s) (concat "* " s "\n")) events ""))))))))
(my-prepare-calendar-for-export)

I used a similar technique to generate the EmacsConf crontabs for automatically switching to the next talk. For that one, I used Emacs Lisp to write the files directly instead of using the :file header argument for Org Mode source blocks. That made it easier to loop over multiple files.

Hmm. Come to think of it, the technique of "go to a specific subtree and then execute it" is pretty powerful. In the past, I've found it handy to execute source blocks by name. Executing a subtree by custom ID is even more useful because I can easily mix source blocks in different languages or include other information. I think that's worth adding a my-org-execute-subtree-by-custom-id function to my Emacs configuration. Combined with an elisp: link, I can make links that execute functional blocks that might even be in different files. That could be a good starting point for a dashboard.

I love the way Emacs can easily work with files and scripts in different languages on different computers, and how it can help me with times and time zones too. This code should help me avoid brain hiccups and calendar mixups so that people can just enjoy getting together. Now I don't have to worry about whether I remembered to set up cron entries and if I did the math right for the times. We'll see how it holds up!

View org source for this post

2025-06-16 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

Making and re-making: fabric is tuition

| sewing, parenting, life

Sewing together with A+ is helping me learn so much about making and re-making, and about saying yes.

I'm not good at saying yes. Sometimes it's because I have no idea how to make something happen, and I don't want to overcommit. Sometimes it's because I don't think something will be practical or worthwhile. Sometimes it's because I want to spend the time or money on other things instead. Sometimes I don't know how to make it something that she can help with. But A+ asks with shining eyes, and I'm learning, slowly, slowly, to say yes. I'm beginning to trust that the mistakes don't matter as much as the memories do.

2025-06-09 swim dress.jpg

A+ has always had ideas about what she wants to wear. At five, she was all about floor-length dresses. I sewed her A-line dresses in comfortable cotton Lycra, peasant-style dresses that matched my tops. I also made a knee-length swim skirt for her. She liked it and requested a floor-length version so that she could twirl and twirl and twirl at the splash pad. She wore it into the wading pool too, enjoying how it swirled around her, trapping air under the skirt and marvelling as it ballooned. The following year, she asked me to attach a bodice to it to make it a dress. I turned the knee-length skirt into the bodice for the floor-length dress, took out the stitches that had previously narrowed the then-too-large waist, and it was good for another year of twirling.

When she was 6 and in the throes of a Cinderella obsession (we read through 50+ variants of the story from the library), I made her a powder blue charmeuse ballgown with a full-circle skirt supported by the petticoats and tutus that she layered with abandon: 19 layers of tulle in total. She loved curtsying and twirling with that whole shebang at the pretend tea parties she hosted at the playground, and it survived the washing machine surprisingly well.

A+ is nine now and has long outgrown the ballgown, which has been stashed in the closet until I figure out if it's going to become a skirt for her or for me. But she still wears the A-line dresses from years ago, which now reach her knees instead of her ankles. She still likes fanciful clothes. I made a floor-length light blue dress so that she could wear it to her cousin's wedding. She picked out some ribbon for the waist, a lace trim to place near the hem, and some ribbon flowers as embellishments, and she asked me to sew a hooded cloak with a ribbon closure. After the wedding, I trimmed the dress to knee-length and re-sewed the lace close to the new hem so that she could wear it while biking to the playground.

Inevitably, she's beginning to grow up. Her fancy is tempered by a few nods to practicality:

  • knee-length skirts because they don't get in the way of riding her bike
  • she likes stretchy fabric more than woven fabric
  • skorts are great for doing cartwheels or hanging upside down.

We prefer to buy fabric in person so that she can feel the fabric on her skin. At the store, A+ zeroed in on a sheer floral print organza that she had seen on a mannequin in the window display. "The organza doesn't have a border," she said. "We can make it a circle skort." She's learning to think about the characteristics of the fabric and what we can do with it. She matched the sheer floral organza fabric with a peach polyester-spandex from the swimwear section and the right colour of thread from the basement.

This will be the fourth warm-weather skirt this year. We make little tweaks each time, as she learns more about what she likes to wear. Here's the progression so far:

  • For her first summer skirt this year, I made a lavender knee-length rectangle skirt with scallop-edged embroidered mesh over bridal satin, gathered at a 1:2 ratio. She liked the dressiness of it, but 1:2 turned out to not be enough ease for cartwheels, so she changes into something else when she wants to be more active.
  • The second was a mermaid scale skort made from the swimwear fabric I ordered last year, based on one of the purchased skorts she liked. It'll be her new swim skirt.
  • The third one was a purple skort. I changed it from side seams to a single back seam so that it's easier for A+ to tell the difference between front and back.
2025-06-09 variations on a theme.jpg

I love being able to change things based on her feedback. We've browsed nearby clothing stores and bought a few pieces, but she rarely finds things that she really likes. Her last pick from the store was a peach skort with a matching top. I removed the waist elastic from the top because she doesn't like elastics, and now it's good to go. It'll be the model for the new skort, I think: a non-stretchy skirt with a stretchy fabric used for the shorts. This time she wants a knee-length circle skirt instead of the mid-thigh length of the commercial skorts. Easy enough - just a matter of drawing a bigger circle.

2025-06-09 hat.jpg

I appreciate how all this experience re-making things makes it easier to say yes to A+'s ideas. For example, the hat I sewed for her last year is starting to feel a bit small. I cut pieces for a new bucket hat using the free AppleGreen Cottage pattern that I'd previously used to make two other hats for her and one for me. This time, I wanted to make the outer layer from the floral canvas left over from reupholstering long-gone dining room chairs and the inner layer from the linen tea toweling that we decided not to use in the kitchen. Midway through the process, A+ asked me if I could make the hat pointy instead, like a witch's hat. I put the brim pieces together, sketched out a quick quarter-circle, sewed the outer layer, and tested the fit on her head. Then I used the inner brim layer to cover up the seams, finished with some topstitching, and it was good to go. I figured that if she changed her mind and wanted a flat hat, I could easily make one from the scraps, or I could even modify this pointy hat to put a different crown on it.

2025-06-09 doll.jpg

Sewing is becoming more enjoyable and less stressful. I worry less about making mistakes because I've learned how to recover from many of them. Sometimes something's more of a loss, like that lavender floor-length cotton dress with a split organza overskirt that she wore a couple of times before it was declared too uncomfortable, or the scraps that she cuts up when trying to fashion a dress for her doll. That's fine, fabric is tuition for learning.

A+'s becoming more adept, too. She's no longer limited to standing still for measurements, fetching pieces of fabric, or other make-work I could think of to keep her busy while I sewed. Now she can get the sewing machine to wind the bobbin and she can thread the needle. She can sew straight seams and stop when the machine makes an unexpected sound. She can turn straps right side out and unpick seams when we make mistakes or change our minds. She knows it isn't just a matter of how a fabric looks, but also what it feels like and how it moves. She's gradually learning what she likes and what she doesn't like. And if I have the temerity to remind her how to do something ("Make a 'p' shape with the thread when you put it in the bobbin case"), I get a glimpse of the teenager she'll become ("I know how to do it, Mom.").

We're getting better at seeing the clothes as their component parts: patterns, fabric, pieces we can recombine. "Can you copy this, but without sleeves?" she asks, and I figure it out. Looking at the yardage left, I can start to think: ah, I can squeeze a matching training bra out of this part, and I think I have enough here to make a top, and this rectangle is large enough for a headband, and I can turn these scraps into flowers while I'm waiting for her at a playdate.

I'm learning from all her requests. By myself, I tend to settle into comfortable routines. In 2015, I made 18 tops based on the Colette Sorbetto woven tank top pattern, eventually taking advantage of Hacklab's laser cutter to precisely cut the fabric so that my notches lined up every time. When I find something I like, I make it again and again. A+'s still figuring out what she likes. We're learning so much.

Sewing for A+ is a time-limited opportunity, and I want to make the most of it. There are only so many clothes I can sew for her. Eventually she may want to wear the same things as everyone else, or eventually she might be comfortable doing all the sewing herself. Eventually she'll be off on her own life. Maybe the ballgowns will turn into skirts or camisoles, and from there into headbands or scrunchies.

・・・・・

Re-making echoes through our past. My mom tells me this story of how her mom sewed, and how their family was poor. My mom rarely got a new dress, so when her mom sewed a red dress for her, that was special. She wore it until the bodice couldn't fit any more. Her mom undid the seams, sewed a new bodice onto the skirt, and gave the dress back to her. She wore it until the skirt was all worn out. Her mom undid the seams and replaced the skirt. My mom said to her mom, "Does this mean I have a new dress now?" This was not the only dress my grandmother made for her. My mom also tells a story of how one time she hovered by her mom's sewing machine, impatiently waiting for her mom to finish sewing the dress that she was going to wear to a party that day. My grandmother must have also worked on re-making, on learning how to say yes. My mom didn't make clothes for me, but she passed on the stories.

All this reminds me a little of two picture books we borrowed from the library. My Forever Dress by Harriet Ziefurt and Liz Murphy (2009, video) shows how a grandmother extended and transformed a dress as her granddaughter grew. Something from Nothing by Phoebe Gilman (1992, video) retells the Yiddish folktale about Joseph's overcoat, this time with a special blanket that gets worn down and transformed into a jacket, a vest, a tie, a handkerchief, ending as a fabric-covered button. The button gets lost, but it turns into a story. Of the two, I liked Something from Nothing more. I liked the lighter touch it told the story with, and I liked the reminder that cherished things can be turned into stories.

2025-06-09 bag.jpg

A+ was in the kitchen, making a grocery list on an LCD writing tablet. She wanted to buy apple sauce, yogurt cups, and mac and cheese. She wanted to do it herself, with her own money. W- will walk her to the store, let her loose, and meet up with her in front of one of the aisles. "Mama, you can stay home," she said.

Challenge: She wanted her own bag for groceries. Her backpack was too small. I rummaged through the reusable bags hanging on the coat hooks. There's this cotton tote we got from an event, but the straps are too long. When she put it on her shoulder, it threatened to fall down. When she carried the bag by its straps, the bag dragged on the floor. I shortened one strap to see if she can hold it then. The body of the bag itself was too long. I sewed a seam across the bottom. Now it's the right height for her. I shortened the other strap and serged the bottom seam to make it neat. She wanted a pocket for her purse and the KN95 mask that she'll wear in the supermarket. W- was almost ready to head out. "Give me another five minutes and I can make her a pocketed bag," I said. He waited. I opened up the scrap from the bottom, sewed the edges together in the other direction, turned it into a pocket, and sewed it to the top hem of the bag. A+ pronounced it perfect. She tucked her purse, mask, and shopping list into the bag, looped the straps over her shoulder, snugged the bag under her elbow, and headed out into the world.

・・・・・

I still sweat my way through figuring out how to sew what she comes up with, but it's good for me. I make and re-make so she can have things that fit her ideas, and so that she can dream of more. She's learning that her ideas matter. It can take several tries, but we can make them happen together. Someday she'll make and re-make things all on her own.

This post was inspired by the June IndieWeb Carnival theme of Take Two.

View org source for this post

2025-06-09 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post

2025-06-02 Emacs news

| emacs, emacs-news

Links from reddit.com/r/emacs, r/orgmode, r/spacemacs, r/planetemacs, Mastodon #emacs, Bluesky #emacs, Hacker News, lobste.rs, programming.dev, lemmy.world, lemmy.ml, planet.emacslife.com, YouTube, the Emacs NEWS file, Emacs Calendar, and emacs-devel. Thanks to Andrés Ramírez for emacs-devel links. Do you have an Emacs-related link or announcement? Please e-mail me at sacha@sachachua.com. Thank you!

View org source for this post