new-task-modal-linear-spec.md10.3 KBView on GitHub # New-task modal — Linear "New issue" spec
The reference for our create-task surface. Every number below was read off Linear's live
DOM (`getComputedStyle` on the open **New issue** modal, workspace Cedar-OS, team SMAIL,
viewport 1512px), not eyeballed from a screenshot.
Implemented as `apps/mail/modules/userTasks/components/NewTaskDialog.tsx`, used by both the
kanban board and the list view. It replaced an inline card that walked three cmd+k steps
(conversation → description → date); Linear's model is the opposite and is what we now do:
**one modal, everything visible at once, properties as chips you may ignore.**
The geometry below is Linear's and ours is built to it. Where we deliberately differ, see
**Where our modal departs** at the end.
## The shape
```
┌────────────────────────────────────────────────────────────┐ ← 750px, r=21px
│ [SMAIL] › New issue ⤢ ✕ │ ← header, 51px
│ │
│ Issue title │ ← 18px/600
│ Add description… │ ← 15px/450
│ │
│ ( ○ Backlog ) ( ⋯ Priority ) ( JL jesse ) ( ⬡ Project ) │ ← chips, 24px pills
│ ( ▱ Labels ) ( ⋯ ) │
│ │
│ 📎 ( ) Create more [ Create issue ]│ ← footer, 39px
└────────────────────────────────────────────────────────────┘
```
Empty height is **253px**. It grows only with the description.
## Overlay
| | |
|---|---|
| Layout | `flex; align-items: flex-start; justify-content: center` |
| Padding | `108px 12px` |
| Scrim | **none** — background transparent, no `backdrop-filter` |
The modal is pinned 108px from the top of the viewport, not vertically centred, so it does
not move as the description grows. There is no dimming layer; the modal separates from the
page by shadow alone.
## Shell
| | |
|---|---|
| Width | `750px` (also `max-width`) |
| Radius | `21px` |
| Background | `#ffffff` |
| Border | `0.5px solid #e8e8e8` |
| Shadow | `0 9px 48px rgb(0 0 0 / .08), 0 6px 24px rgb(0 0 0 / .10), 0 1px 1px rgb(0 0 0 / .04)` |
| Layout | `flex; flex-direction: column; min-height: 100px` |
The 0.5px border and the three-layer shadow both matter: one large soft ambient shadow, one
mid-range shadow, and a 1px contact shadow. A single `box-shadow` reads flat next to it.
## Header — 51px
- Row padding `12px`.
- Breadcrumb: `flex; align-items: center; gap: 6px`, 23px tall.
- Team badge is itself a pill (67×23) containing icon + name.
- `›` separator and `New issue`: **13px / weight 450**, `line-height: normal`.
- Right actions: `flex; align-items: center; gap: 6px; padding-left: 12px` — two **27×27**
icon buttons (expand, close).
## Body — 111px when empty
Nested padding, which is what gives the text a wider inset than the header:
- Outer: `padding: 0 6px`
- Inner column: `padding: 0 12px; gap: 6px; align-items: stretch`
- ⇒ effective side inset **18px** for title and description vs **12px** for header/footer.
**Title** — contenteditable, not an `<input>`:
| | |
|---|---|
| Font | `18px / 600 / 28.8px` |
| Colour | `#1b1b1b` |
| Placeholder | `Issue title`, `lch(66 1 282)` ≈ `#9d9d9f` |
| Padding | wrapper `2px 0 0`, inner `3px 0 0` |
**Description** — contenteditable:
| | |
|---|---|
| Font | `15px / 450 / 24px` |
| Colour | `#303032` |
| Placeholder | `Add description…` (real ellipsis), same grey as title |
| Padding | `6px 0 12px` |
| Min-height | `79.2px` |
Placeholders are `::before` on the empty `<p>`, so they disappear per-block as you type —
not a `placeholder` attribute on the whole field.
## Property chips — row is 41px
Row: `padding: 6px 12px 12px; display: flex; align-items: center; gap: 6px`.
Each chip:
| | |
|---|---|
| Height | `24px` |
| Radius | `9999px` |
| Padding | `0 8px 0 6px` — tighter on the icon side |
| Font | `12px / 500 / 12px` |
| Colour | `#5e5e60` |
| Background | `#ffffff` |
| Min-width | `32px` |
The visible outline is **not** a border (the border is `0.5px solid transparent`). It comes
from an `::after` overlay at `inset: 0; border-radius: 9999px`:
```css
box-shadow:
0 0 0 0.5px rgb(0 0 0 / .088),
0 3px 6px -2px rgb(0 0 0 / .02),
0 1px 1px rgb(0 0 0 / .04);
```
Doing it this way means the ring never affects layout and can cross-fade on hover. The
overflow `⋯` chip is a 23×23 circle with the same treatment.
Chips carry a **value when set and a field name when not** — `Backlog` (set) sits next to
`Priority` and `Labels` (unset). An unset chip is not disabled or greyed; it reads the same
as a set one. That is what keeps the row from looking like a form.
## Footer — 39px
Row: `padding: 0 12px 12px; display: flex; align-items: center; justify-content: space-between; gap: 18px`.
- Left: 27×27 attachment icon button.
- Right: toggle + `Create more` label (12px) + primary button.
Primary button:
| | |
|---|---|
| Height | `28px` |
| Padding | `0 10px` |
| Radius | `9999px` |
| Background | `#6e78d5` |
| Colour | `#ffffff` |
| Font | `12px / 500` |
| Shadow | `0 3px 6px -2px rgb(0 0 0 / .02), 0 1px 1px rgb(0 0 0 / .04)` |
| Min-width | `28px` |
It is disabled (reduced opacity, no colour change) until the title is non-empty.
## The rules worth carrying over
1. **Everything is a 6px multiple.** 6, 12, 18, 24. No 5px, no 10px, no 14px.
2. **Four type sizes only** — 18/600 title, 15/450 body, 13/450 header meta, 12/500 controls.
Weight 450 is not a typo; Linear uses a variable font and 450 sits between regular and medium.
3. **Every control is a full pill** (`9999px`) at 24px or 28px tall. The modal itself is the
only square-ish surface, at 21px.
4. **Rings are shadows, not borders.** Keeps hit areas stable and lets hover cross-fade.
5. **No scrim.** Depth is shadow, not dimming.
6. **One surface.** No card inside the modal, no bordered section around the chips, no rule
between body and footer. Padding does all the separating — which matches our own
"one surface per object" rule in CLAUDE.md.
7. **Optional fields are chips, never rows.** A label-and-control row per property would
triple the height and make an optional field look required.
8. **The primary action is the only coloured thing on screen.**
## Where our modal departs
Everything above is Linear. These are the places `NewTaskDialog` knowingly does something
else, and why.
| | Linear | Ours |
|---|---|---|
| Type sizes | 18 / 15 / 13 / 12 | 18 / 14 / 12 (standard Tailwind scale) |
| Body weight | 450 | `font-medium` |
| Primary button | `#6e78d5` | our `primary` token |
| Scrim | never | dimmed, and clicking it dismisses |
| "Create more" | a footer toggle | dropped |
| Labels | a chip | dropped for now |
| Enter in the title | moves to the description | opens the deal search |
| Footer left slot | attachment button | the property chips |
| Abandoning a draft | discarded | kept in `localStorage` |
- **Type sizes and weight** — arbitrary px text sizes are banned in this app, so 15px and
13px round to the nearest scale step. This is the one place the pixel-match is given up.
- **Scrim, and clicking it dismisses** — `DIM_BACKDROP` at the top of the component; flip it
to `false` for Linear's shadow-only treatment. A task is composed against a board you are
still reading, and the scrim is what says "answer this first".
- **No "Create more"** — Linear's toggle is for filing a backlog in one sitting. Tasks here
are made one at a time, from a board you are already looking at.
**The chips live in the footer row.** Linear puts an attachment button in the left slot and
the properties on their own row above it. We have no attachments, so the chips take that slot
and the row keeps its `justify-between` shape — one row instead of two, which is ~30px of
height back on a modal that is mostly empty space.
### The Enter chain
Typing the title and pressing Enter walks the two properties that almost every task needs:
```
title ──Enter──▶ deal search ──pick──▶ due date ──pick──▶ done
```
The description comes last because it is the one field a task might not need at all. Every
chip is still directly clickable, so the chain is an offer rather than a rail. `⌘↵` creates
the task from anywhere in the modal; the button carries no shortcut hint — a `Kbd` glyph
inside a 28px pill crowds it.
Two things make the chain work that are not obvious from the code:
- **Each picker must not restore focus on close.** Radix hands focus back to the trigger by
default, which lands *after* the next step has taken it — the chain would stop dead at the
deal, and the date picker would open focused-then-stolen. Both popovers pass
`onCloseAutoFocus={(e) => e.preventDefault()}` so the chain decides where focus goes.
- **The handoff waits `PICKER_HANDOFF_MS` (170ms).** Popovers animate over 150ms. Opening the
next picker on the following frame starts its enter animation while the previous one is
still exiting; the two interfere and the incoming layer sticks part-way through its fade —
open, focused, and all but invisible at `opacity: 0.08`. Letting the outgoing one finish
first is the whole fix.
**Picking a deal fills the due date from that deal's `next_step_date`** and then opens the
date picker on top of it. A task about a deal is due when the deal is next due an update;
asking again for a date the CRM already holds is a question software should answer for
itself. The picker still opens, so the default is a starting point, not a decision made for
the user. `crm.searchConversationsMinimal` returns `nextStepDate` for exactly this.
### Abandoning a draft costs nothing
Dismissing — Escape, the close button, a click on the scrim — writes what is there to
`localStorage` under `cedar:new-task-draft`. The next `c` reopens the modal exactly as it was
left; creating the task clears it. There is deliberately no "discard your changes?" prompt:
that dialog is the app asking the user to clean up after a decision the app made.