Skip to main content

max / goingson

Give the three bulk-apply controls a commit affordance Priority, project and snooze applied a value to the selection by writing as the picker changed, so one silent interaction mutated every ticked task at once and most of them were off screen. A tick stages and never writes (`5f2b8753`), which left this bar half staged and half live. They are acts that ask now. Pressing "Set priority" is the commit, and the picker it reveals is the value that commit carries, which is what wiki `explicit-commit-affordance` asks for: a change staged, and a separate visible control locking it in. The bar reads as one kind of control throughout. The shape is not new. This app's own email bar already said Snooze this way, and so does MNW's content table for Set Price and Add Tag. The task list was the last bulk bar in the tree using the picker-that-writes shape, and quasi 0.76.1 rules against it. The blank leading option on each picker is gone with it. It stood for the resting state of a select that is always on screen, and a picker revealed by a press has no resting state to show. Handlers are unchanged and still answer an unparseable value with the list unchanged; nothing sends one now.
Author: Max Johnson <me@maxj.phd> · 2026-08-28 22:28 UTC
Signed with PGP, not checked
Commit: 2554f3370624d0a33ad721a15597baa9a08d0de2
Parent: 0a93dfc
1 file changed, +45 insertions, -39 deletions
@@ -738,10 +738,17 @@
738 738
739 739 /// The controls over the selection.
740 740 ///
741 - /// The shipped bar's five, and its two halves are two kinds of control.
742 - /// Complete and Delete act on the set; snooze, project and priority apply a
743 - /// value to it, which is why [`Field::over`] had to exist — said as acts, the
744 - /// project picker alone is one button per project.
741 + /// The shipped bar's five, all of them acts. Complete and Delete act on the set
742 + /// and carry no value; priority, project and snooze apply a value, and reach it
743 + /// through [`Act::asks`] rather than by being pickers that write as they change.
744 + ///
745 + /// The picker was the reason [`Field::over`] was added: said as plain acts, a
746 + /// project picker is one button per project. `Act::asks` answers that without
747 + /// the write, because the value rides with the press instead of firing on its
748 + /// own, so a picker over forty projects is still one control. Under
749 + /// wiki `explicit-commit-affordance` the press is also the commit the reader
750 + /// needs, and a bar whose ticks stage while its pickers write was half staged
751 + /// and half live.
745 752 ///
746 753 /// # What is not here
747 754 ///
@@ -785,57 +792,56 @@
785 792 ));
786 793 }
787 794
788 - // A blank leading option on each picker, because a select opens on its
789 - // first option and one that opened on "High" would read as though the
790 - // selection already had a priority. Choosing the blank writes nothing: the
791 - // handlers answer an unparseable value with the list unchanged rather than
792 - // an error, which is the one place on this screen a bad value is not a 404
793 - // — it is the control's resting state, not a wiring mistake.
794 - let mut priorities = vec![Choice::new("", "Set priority")];
795 - priorities.extend(
796 - PRIORITIES
797 - .iter()
798 - .map(|priority| Choice::new(priority_word(priority), priority_word(priority))),
799 - );
800 - out.push(Node::field(
801 - Field::select("priority", "Set priority", priorities)
802 - .writes(over(Action::post("/tasks/list/priority")))
803 - .over(SELECTION),
795 + // The three value controls are acts that ask, not fields that write. A tick
796 + // stages and never writes (`5f2b8753`), so a bar whose ticks stage and whose
797 + // pickers write on change is half staged and half live. Pressing the verb is
798 + // the commit, and the picker it reveals is the value that commit carries.
799 + //
800 + // No blank leading option any more. It existed because a bare select opens
801 + // on its first option, and one opening on "High" read as though the
802 + // selection already had a priority. A picker that is not on screen until
803 + // the verb is pressed cannot say that, so the resting state has nowhere to
804 + // be and the option that stood for it is gone. The handlers still answer an
805 + // unparseable value with the list unchanged, which now covers nothing this
806 + // screen sends.
807 + let priorities: Vec<Choice> = PRIORITIES
808 + .iter()
809 + .map(|priority| Choice::new(priority_word(priority), priority_word(priority)))
810 + .collect();
811 + out.push(Node::Act(
812 + Act::new("Set priority", over(Action::post("/tasks/list/priority")))
813 + .over(SELECTION)
814 + .asking(Field::select("priority", "Priority", priorities)),
804 815 ));
805 816
806 817 let projects = state
807 818 .projects
808 819 .list_all(DESKTOP_USER_ID)
809 820 .map_err(|error| RouteError::internal(error.to_string()))?;
810 - let mut options = vec![
811 - Choice::new("", "Set project"),
812 - Choice::new(NONE, "No project"),
813 - ];
821 + let mut options = vec![Choice::new(NONE, "No project")];
814 822 options.extend(
815 823 projects
816 824 .iter()
817 825 .map(|project| Choice::new(project.id.to_string(), &project.name)),
818 826 );
819 - out.push(Node::field(
820 - Field::select("project", "Set project", options)
821 - .writes(over(Action::post("/tasks/list/project")))
822 - .over(SELECTION),
827 + out.push(Node::Act(
828 + Act::new("Set project", over(Action::post("/tasks/list/project")))
829 + .over(SELECTION)
830 + .asking(Field::select("project", "Project", options)),
823 831 ));
824 832
825 833 // The same precomputed times the shipped modal offers, from the same
826 834 // function, so "Later Today" means one thing in the app. A described screen
827 835 // has no modal to put them in and does not need one: they are options.
828 - let mut whens = vec![Choice::new("", "Snooze until")];
829 - whens.extend(
830 - crate::commands::get_snooze_options()
831 - .options
832 - .into_iter()
833 - .map(|option| Choice::new(option.time.to_rfc3339(), option.label)),
834 - );
835 - out.push(Node::field(
836 - Field::select("until", "Snooze until", whens)
837 - .writes(over(Action::post("/tasks/list/snooze")))
838 - .over(SELECTION),
836 + let whens: Vec<Choice> = crate::commands::get_snooze_options()
837 + .options
838 + .into_iter()
839 + .map(|option| Choice::new(option.time.to_rfc3339(), option.label))
840 + .collect();
841 + out.push(Node::Act(
842 + Act::new("Snooze until", over(Action::post("/tasks/list/snooze")))
843 + .over(SELECTION)
844 + .asking(Field::select("until", "Snooze until", whens)),
839 845 ));
840 846
841 847 Ok(out)