Commit 3a24802c by PLN (Algolia)

pulsar: errors are information, not punishment

The notifications package auto-hides a notification after 5 s only when it
is NOT dismissable. Everything raised through addError/addFatalError is
dismissable, so every error waits for a click forever, at 450 px of pale-red
fill with near-black text over a dark theme — and a fatal one SHAKES, three
times, which is punitive UX with an actual keyframe.

Nothing is lost by hiding them: the same package keeps a timestamped log of
every notification, reachable with notifications:toggle-log. That is what
makes a timeout safe rather than careless — it hides the interruption, not
the information.

notifications.js gives every dismissable notification a life, longer the
worse it is (4 s info to 25 s fatal). Hovering pauses it, because reading is
not dismissing. Clicking cancels it for good, because a click means "I am
working on this". Notifications the package already auto-hides are left
alone: one owner per behaviour.

notifications.less makes the panel dark, translucent and blurred so the code
underneath stays readable, moves the type into the colour of the icon strip
instead of a wall of fill, deletes the shake, and draws a 2 px countdown bar
from the same duration the script used — read through a custom property, so
the bar cannot disagree with the timer. Ship's Bridge tokens, so the editor
and the armada UIs say "error" in the same colour.

They live here rather than in ~/.pulsar so the reasoning is in git; the
dotfiles hold one require and one @import. The stylesheet was compiled
before landing. init.js needs a window reload to take effect; styles.less is
live on save.
parent 7a88f1d8
// Errors are information, not punishment.
//
// PLN, 2026-09-25, mid-rehearsal: *"fix the errors being ugly big red things
// that stick? I do errors. embrace errors. stop being punitive UX. transparent
// windows with timeouts anyone?"*
//
// WHY THEY STICK. Pulsar's `notifications` package auto-hides a notification
// after `visibilityDuration` (5 s) — but ONLY when it is not dismissable.
// Everything raised through `addError`/`addFatalError`, which is every error a
// package reports, is dismissable, and a dismissable notification waits for a
// click forever. Source: `notifications/lib/notification-element.js` in the
// Pulsar bundle. So the stack grows over the code while you play.
//
// NOTHING IS LOST BY HIDING THEM. The same package keeps a LOG of every
// notification it ever showed, with timestamps: `notifications:toggle-log`, or
// the ⚑ in the status bar. That is what makes a timeout safe rather than
// careless — this hides the interruption, not the information.
//
// THE RULES, in the order they matter:
// * every notification gets a timeout, longer the worse it is;
// * hovering one PAUSES its timeout — reading is not dismissing;
// * CLICKING one cancels the timeout for good, because a click means
// "I am working on this"; and
// * a notification the package already auto-hides is left alone: one owner
// per behaviour.
//
// Loaded from `~/.pulsar/init.js` so it lives in git instead of in a dotfile.
(function () {
// Milliseconds on screen, by type. A warning you can read at a glance; a
// fatal is a crash and deserves long enough to copy a stack trace out of.
const LIFE = {
success: 4000,
info: 5000,
warning: 9000,
error: 14000,
fatal: 25000,
};
const FALLBACK = 8000;
atom.notifications.onDidAddNotification(function (n) {
try {
// Non-dismissable ones already disappear on the package's own 5 s timer.
// Touching them would be two owners for one fact.
if (!n.isDismissable()) return;
const ms = LIFE[n.getType()] || FALLBACK;
// `init.js` is evaluated AFTER packages are activated, so the
// notifications package's own listener has already run and its element
// is the newest child of the stack. Guarded: if that ever stops being
// true we keep the timer and lose only the hover-pause and the bar.
const stack = document.querySelector("atom-notifications");
const el = stack ? stack.lastElementChild : null;
let timer = null;
const hold = function () {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const arm = function () {
hold();
timer = setTimeout(function () {
if (!n.isDismissed()) n.dismiss();
}, ms);
};
if (el) {
// The stylesheet reads this to draw the countdown, so the duration has
// exactly one source: the table above.
el.style.setProperty("--pv-life", ms + "ms");
el.classList.add("pv-timed");
el.addEventListener("mouseenter", function () {
el.classList.add("pv-held");
hold();
});
el.addEventListener("mouseleave", function () {
el.classList.remove("pv-held");
if (!el.classList.contains("pv-kept")) arm();
});
// Capture: the notification's own buttons stop propagation, and a click
// on any of them still means "I am dealing with this".
el.addEventListener("click", function () {
el.classList.add("pv-kept");
hold();
}, true);
}
n.onDidDismiss(hold);
arm();
} catch (e) {
// A nicety must never be able to break the editor it is decorating.
console.warn("pv notifications: " + e);
}
});
})();
//
// Notifications that inform instead of scold.
//
// PLN, 2026-09-25: *"errors being ugly big red things that stick … stop being
// punitive UX. transparent windows with timeouts anyone?"*
//
// WHAT THE STOCK STYLE DOES, from `notifications/styles/notifications.less`
// in the Pulsar bundle:
// * `.content` gets `background-color: lighten(@background-color-error, 25%)`
// and `color: darken(@text-color-error, 40%)` — a 450 px pale-red panel
// with near-black text, dropped on top of a dark theme. Maximum contrast
// against everything around it, which is why it reads as an alarm.
// * a `fatal` notification SHAKES: `notification-shake 4s 2s`, three times.
// That is the punitive bit, literally animated.
// * the container is `font-size: 1.2em`, which on a UI already at 20 px is
// a 24 px error message.
//
// WHAT THIS DOES INSTEAD. Same information, same position, same close button:
// * one dark translucent panel for every type, blurred, see-through enough
// to read the code underneath;
// * the TYPE lives in the colour of the icon strip, not in a wall of fill;
// * no shaking, ever;
// * a 2 px countdown bar showing the timeout from `notifications.js`, paused
// while hovered and stopped for good once clicked.
//
// Loaded from `~/.pulsar/styles.less` so it lives in git instead of a dotfile.
// Ship's Bridge tokens (armada/DESIGN.md), so the editor and the armada UIs
// say "error" in the same colour.
@pv-ok: #5bc091;
@pv-warn: #e0a82e;
@pv-err: #ff5252;
@pv-info: #36c5f0;
@pv-ink: #e8e8ea;
@pv-panel: #101014;
atom-notifications {
font-size: 1em; // was 1.2em on top of the UI's own size
atom-notification {
width: 390px;
opacity: 0.8;
transition: opacity 120ms ease-out;
&:hover,
&.pv-held,
&.pv-kept {
opacity: 1;
}
// NO SHAKING. Keep the show animation, drop the three shakes after it.
&[type="fatal"],
&.fatal {
-webkit-animation: notification-show 0.16s cubic-bezier(0.175, 0.885, 0.32, 1.27499);
-webkit-animation-iteration-count: 1;
}
.content {
position: relative; // the countdown bar anchors here
background-color: fade(@pv-panel, 78%);
-webkit-backdrop-filter: blur(12px) saturate(130%);
backdrop-filter: blur(12px) saturate(130%);
color: @pv-ink;
box-shadow: 0 6px 24px fade(#000, 35%);
}
a { color: @pv-info; }
code {
color: @pv-ink;
background-color: fade(#fff, 8%);
}
// The stack trace panel is `hsla(0,0%,100%,.3)` by default: a white sheet
// inside a dark one. Keep it a shade apart, on the dark side.
.detail {
background-color: fade(#000, 28%);
color: fade(@pv-ink, 88%);
}
// `.close` is hard-coded to black, which vanishes on a dark panel.
.close {
color: @pv-ink;
opacity: 0.45;
&:hover, &:focus { opacity: 1; }
}
.close-all.btn {
border-color: fade(@pv-ink, 25%);
color: fade(@pv-ink, 70%);
&:hover { color: @pv-ink; border-color: fade(@pv-ink, 55%); }
}
// ── the countdown ──────────────────────────────────────────────────────
// `--pv-life` is set by notifications.js from its own table, so the bar
// cannot disagree with the timer. No variable = no bar, which is the right
// failure: a notification with no timeout must not claim to have one.
&.pv-timed .content::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
height: 2px;
width: 100%;
background: currentColor;
opacity: 0.3;
transform-origin: left center;
-webkit-animation: pv-notif-life var(--pv-life) linear forwards;
animation: pv-notif-life var(--pv-life) linear forwards;
}
// Hovering pauses the timer, so it must pause the bar too, or the bar is
// lying about what will happen.
&.pv-held .content::after {
-webkit-animation-play-state: paused;
animation-play-state: paused;
}
// Clicked = kept. No bar at all: there is nothing left to count down.
&.pv-kept .content::after {
display: none;
}
}
// The type, in one strip down the left edge, at a brightness that reads
// without shouting.
atom-notification.fatal,
atom-notification.error {
&.icon:before { background-color: fade(@pv-err, 70%); color: #fff; }
}
atom-notification.warning {
&.icon:before { background-color: fade(@pv-warn, 70%); color: #201800; }
}
atom-notification.info {
&.icon:before { background-color: fade(@pv-info, 70%); color: #001820; }
}
atom-notification.success {
&.icon:before { background-color: fade(@pv-ok, 70%); color: #002014; }
}
}
@-webkit-keyframes pv-notif-life {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
@keyframes pv-notif-life {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment