@novacraft-engineering/mailbox 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,16 @@ export function anchoredMenuStyle(anchor: HTMLElement): React.CSSProperties {
12
12
  : { ...base, top: rect.bottom + 6 }
13
13
  }
14
14
 
15
+ export const GLYPH = {
16
+ font: <span className={styles.selectGlyphSerif}>A</span>,
17
+ size: 'tT',
18
+ spacing: (
19
+ <svg viewBox="0 0 12 12" fill="none" aria-hidden>
20
+ <path d="M3 2v8M1.6 3.6L3 2l1.4 1.6M1.6 8.4L3 10l1.4-1.6M6.5 4.2h4M6.5 7.8h4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
21
+ </svg>
22
+ ),
23
+ }
24
+
15
25
  export const CHEVRON = (
16
26
  <svg className={styles.selectChevron} viewBox="0 0 12 8" fill="none" aria-hidden>
17
27
  <path d="M1 1l5 5 5-5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
@@ -28,11 +38,10 @@ export default function MailSelect({
28
38
  editable = false,
29
39
  optionStyle,
30
40
  prefix,
31
- compact = false,
32
- title,
41
+ label,
33
42
  }: {
34
43
  value: string
35
- options: Array<{ value: string; label: string }>
44
+ options: Array<{ value: string; label: string; closed?: string }>
36
45
  onChange: (value: string) => void
37
46
  ariaLabel: string
38
47
  buttonClassName?: string
@@ -40,11 +49,8 @@ export default function MailSelect({
40
49
  /** Typed values are accepted too: committed on Enter or when the field loses focus. */
41
50
  editable?: boolean
42
51
  optionStyle?: (value: string) => React.CSSProperties
43
- /** A quiet glyph before the value, so three selects in a row can be told apart. */
44
52
  prefix?: React.ReactNode
45
- /** Closed, show only the glyph; the value stays in the title and the menu. */
46
- compact?: boolean
47
- title?: string
53
+ label?: string
48
54
  }) {
49
55
  const [open, setOpen] = useState(false)
50
56
  const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({})
@@ -72,24 +78,26 @@ export default function MailSelect({
72
78
  }, [open])
73
79
 
74
80
  const current = options.find(option => option.value === value)
81
+ const shown = current?.closed ?? current?.label
75
82
  const toggle = () => {
76
83
  if (!open && anchorRef.current) setMenuStyle(anchoredMenuStyle(anchorRef.current))
77
84
  setOpen(prev => !prev)
78
85
  }
79
86
  const commitTyped = () => {
80
- if (typed !== null && typed !== (current?.label ?? value)) onChange(typed.trim())
87
+ if (typed !== null && typed !== (shown ?? value)) onChange(typed.trim())
81
88
  setTyped(null)
82
89
  }
83
90
 
84
91
  return (
85
92
  <span className={styles.select} ref={wrapRef}>
86
93
  {editable ? (
87
- <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`} title={title}>
94
+ <span ref={anchorRef as React.RefObject<HTMLSpanElement>} className={`${styles.selectBtn} ${styles.selectEditable} ${buttonClassName ?? ''}`}>
88
95
  {prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
96
+ {label && <span className={styles.selectLabel}>{label}</span>}
89
97
  <input
90
98
  className={styles.selectInput}
91
99
  aria-label={ariaLabel}
92
- value={typed ?? current?.label ?? value}
100
+ value={typed ?? shown ?? value}
93
101
  placeholder={placeholder}
94
102
  onFocus={() => { if (!open) toggle() }}
95
103
  onChange={event => setTyped(event.target.value)}
@@ -113,12 +121,12 @@ export default function MailSelect({
113
121
  className={`${styles.selectBtn} ${buttonClassName ?? ''}`}
114
122
  aria-haspopup="listbox"
115
123
  aria-expanded={open}
116
- aria-label={compact ? `${ariaLabel}: ${current?.label ?? placeholder}` : ariaLabel}
117
- title={title ?? (compact ? `${ariaLabel}: ${current?.label ?? placeholder}` : undefined)}
124
+ aria-label={ariaLabel}
118
125
  onClick={toggle}
119
126
  >
120
127
  {prefix && <span className={styles.selectGlyph} aria-hidden>{prefix}</span>}
121
- {!compact && <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{current?.label ?? placeholder}</span>}
128
+ {label && <span className={styles.selectLabel}>{label}</span>}
129
+ <span style={optionStyle && current?.value ? optionStyle(current.value) : undefined}>{shown ?? placeholder}</span>
122
130
  {CHEVRON}
123
131
  </button>
124
132
  )}
@@ -10,7 +10,7 @@ import { CLIENT_BRAND } from '@/lib/brand.client'
10
10
  import TextAlign from '@tiptap/extension-text-align'
11
11
  import { FontFamily, FontSize, TextStyle } from '@tiptap/extension-text-style'
12
12
  import { LINE_SPACINGS, BUILTIN_FONTS, FONT_SIZES, fontStack, lineSpacingOf, paragraphGap, type BaseFont } from '@/lib/fonts'
13
- import MailSelect from './MailSelect'
13
+ import MailSelect, { GLYPH } from './MailSelect'
14
14
  import { Color } from '@tiptap/extension-color'
15
15
  import Highlight from '@tiptap/extension-highlight'
16
16
  import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table'
@@ -631,12 +631,10 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
631
631
  <MailSelect
632
632
  buttonClassName={styles.rteSelect}
633
633
  ariaLabel="Font"
634
- placeholder={`${defaultFamily} (default)`}
635
- prefix={<span className={styles.selectGlyphSerif}>A</span>}
636
- compact
637
- title={`Font: ${currentFamily || `${defaultFamily} (default)`}`}
634
+ label="Font"
635
+ prefix={GLYPH.font}
638
636
  value={currentFamily}
639
- options={[{ value: '', label: `${defaultFamily} (default)` }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
637
+ options={[{ value: '', label: `${defaultFamily} (default)`, closed: defaultFamily }, ...[...BUILTIN_FONTS, ...fonts.filter(font => !BUILTIN_FONTS.includes(font))].map(font => ({ value: font, label: font }))]}
640
638
  optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
641
639
  onChange={family => {
642
640
  if (family) editor.chain().focus().setFontFamily(family).run()
@@ -646,12 +644,12 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
646
644
  <MailSelect
647
645
  buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
648
646
  ariaLabel="Font size"
647
+ label="Size"
649
648
  placeholder={defaultSize}
650
- prefix="tT"
651
- title={`Size — default ${defaultSize}`}
649
+ prefix={GLYPH.size}
652
650
  editable
653
651
  value={(editor.getAttributes('textStyle').fontSize as string | undefined) ?? ''}
654
- options={[{ value: '', label: `${defaultSize} (default)` }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
652
+ options={[{ value: '', label: `${defaultSize} (default)`, closed: defaultSize }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
655
653
  onChange={raw => {
656
654
  const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
657
655
  if (!size || /default/i.test(size)) editor.chain().focus().unsetFontSize().run()
@@ -660,13 +658,13 @@ function Toolbar({ editor, uploadImage, fonts = [], baseFont }: { editor: Editor
660
658
  />
661
659
  <MailSelect
662
660
  buttonClassName={`${styles.rteSelect} ${styles.rteSizeSelect}`}
663
- ariaLabel="Line spacing"
661
+ ariaLabel="Line height"
662
+ label="Line height"
664
663
  placeholder={defaultSpacing}
665
- prefix="↕"
666
- title={`Line spacing — default ${defaultSpacing}`}
664
+ prefix={GLYPH.spacing}
667
665
  editable
668
666
  value={editor.getAttributes('paragraph').lineSpacing ? String(editor.getAttributes('paragraph').lineSpacing) : ''}
669
- options={[{ value: '', label: `${defaultSpacing} (default)` }, ...LINE_SPACINGS.map(spacing => ({ value: String(spacing), label: String(spacing) }))]}
667
+ options={[{ value: '', label: `${defaultSpacing} (default)`, closed: defaultSpacing }, ...LINE_SPACINGS.map(spacing => ({ value: String(spacing), label: String(spacing) }))]}
670
668
  onChange={raw => {
671
669
  const spacing = parseFloat(raw)
672
670
  if (!raw.trim() || /default/i.test(raw)) editor.chain().focus().updateAttributes('paragraph', { lineSpacing: null }).run()
@@ -1681,7 +1681,7 @@
1681
1681
  border-radius: 6px;
1682
1682
  background: var(--nc-ink-1);
1683
1683
  color: var(--nc-fg-1);
1684
- font: 500 12px/1 var(--nc-font-sans);
1684
+ font: 500 11px/1 var(--nc-font-sans);
1685
1685
  }
1686
1686
  .rteSwatches { display: flex; gap: 3px; }
1687
1687
  .rteSwatch {
@@ -2221,13 +2221,22 @@
2221
2221
  .selectBtn:hover { border-color: var(--nc-line-2); color: var(--nc-fg-1); }
2222
2222
  .selectGlyph {
2223
2223
  flex-shrink: 0;
2224
- font: 600 11px/1 var(--nc-font-mono);
2225
- letter-spacing: .02em;
2226
- color: var(--nc-fg-4);
2224
+ display: inline-flex;
2225
+ align-items: center;
2226
+ justify-content: center;
2227
+ width: 16px;
2228
+ height: 16px;
2229
+ font: 700 11px/1 var(--nc-font-sans);
2230
+ letter-spacing: .01em;
2231
+ color: var(--nc-fg-3);
2232
+ }
2233
+ .selectGlyph svg { width: 13px; height: 13px; }
2234
+ .selectGlyphSerif { font: 700 13px/1 Georgia, 'Times New Roman', serif; }
2235
+ .selectLabel {
2236
+ flex-shrink: 0;
2237
+ color: var(--nc-fg-3);
2238
+ white-space: nowrap;
2227
2239
  }
2228
- .selectGlyphSerif { font: 600 14px/1 Georgia, 'Times New Roman', serif; color: var(--nc-fg-3); }
2229
- .selectEditable .selectGlyph { margin-left: 9px; }
2230
- .selectEditable .selectGlyph + .selectInput { padding-left: 6px; }
2231
2240
  .selectBtn > span {
2232
2241
  overflow: hidden;
2233
2242
  text-overflow: ellipsis;
@@ -2270,6 +2279,7 @@
2270
2279
  .selectOptionOn { background: var(--nc-ink-4); color: var(--nc-violet-200); }
2271
2280
 
2272
2281
  .toolbar .selectBtn { padding: 7px 10px; font-size: 12px; }
2282
+ .selectBtn.rteSelect { font-size: 11.5px; }
2273
2283
  .campaignSelectBtn {
2274
2284
  width: 100%;
2275
2285
  justify-content: space-between;
@@ -6592,20 +6602,23 @@
6592
6602
  .fontInput { flex: 1 1 160px; height: 34px; padding: 0 10px; border: 1px solid var(--nc-line-1); border-radius: 8px; background: var(--nc-ink-1); color: var(--nc-fg-1); font: 500 13px/1 var(--nc-font-sans); }
6593
6603
  .fontUpload { height: 34px; display: inline-flex; align-items: center; padding: 0 12px; border: 1px solid var(--nc-line-1); border-radius: 8px; font: 500 13px/1 var(--nc-font-sans); color: var(--nc-fg-1); cursor: pointer; }
6594
6604
 
6595
- .selectEditable { padding: 0 6px 0 0; gap: 0; }
6596
- .selectInput {
6605
+ .selectEditable { padding: 0 6px 0 8px; gap: 6px; }
6606
+ .selectBtn .selectInput {
6597
6607
  width: 3.5em;
6598
6608
  min-width: 0;
6599
6609
  height: 100%;
6600
- padding: 0 0 0 10px;
6610
+ padding: 0;
6601
6611
  border: 0;
6612
+ border-radius: 0;
6602
6613
  background: transparent;
6614
+ box-shadow: none;
6615
+ appearance: none;
6603
6616
  color: inherit;
6604
6617
  font: inherit;
6605
6618
  outline: none;
6606
6619
  }
6607
- /* Wide enough for the word it actually shows: at 3.5em "Default" arrived as "Defi". */
6608
- .rteSizeSelect .selectInput { padding-left: 8px; width: 6em; }
6620
+ .selectBtn .selectInput:focus { border: 0; box-shadow: none; }
6621
+ .rteSizeSelect .selectInput { width: 3.2em; }
6609
6622
  .selectChevronBtn { display: inline-flex; align-items: center; padding: 0 4px; border: 0; background: transparent; color: inherit; cursor: pointer; }
6610
6623
  .mailboxDomainSelect.selectBtn { padding: 4px 6px; background: transparent; border: 0; }
6611
6624
 
package/app/mail/page.tsx CHANGED
@@ -10,7 +10,7 @@ import { subscribePush, unsubscribePush, useInstall, useNotifications } from './
10
10
  import RichEditor from './RichEditor'
11
11
  import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeImages, stripOwnPixel } from '@/lib/email-html'
12
12
  import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
13
- import MailSelect from './MailSelect'
13
+ import MailSelect, { GLYPH } from './MailSelect'
14
14
  import Ticker from './Ticker'
15
15
  import { applyThreadFlagDeltas, normalizeSubject } from '@/lib/threads'
16
16
  import { defaultSignature, fillSignature } from '@/lib/default-signature'
@@ -8221,19 +8221,19 @@ export default function DevMailPage() {
8221
8221
  <div className={styles.fontAdd}>
8222
8222
  <MailSelect
8223
8223
  ariaLabel="Default font"
8224
- prefix={<span className={styles.selectGlyphSerif}>A</span>}
8224
+ prefix={GLYPH.font}
8225
8225
  value={settings.defaultFont?.family ?? ''}
8226
- options={[{ value: '', label: 'Arial (standard)' }, ...[...BUILTIN_FONTS, ...(settings.fonts ?? []).map(font => font.name)].filter((font, index, all) => all.indexOf(font) === index).map(font => ({ value: font, label: font }))]}
8226
+ options={[{ value: '', label: 'Arial (standard)', closed: 'Arial' }, ...[...BUILTIN_FONTS, ...(settings.fonts ?? []).map(font => font.name)].filter((font, index, all) => all.indexOf(font) === index).map(font => ({ value: font, label: font }))]}
8227
8227
  optionStyle={font => (font ? { fontFamily: `'${font}', Arial, sans-serif` } : {})}
8228
8228
  onChange={family => setMailSettings(current => ({ ...current, defaultFont: { ...(current.defaultFont ?? EMPTY_FONT), family } }))}
8229
8229
  />
8230
8230
  <MailSelect
8231
8231
  ariaLabel="Default size"
8232
- prefix="tT"
8232
+ prefix={GLYPH.size}
8233
8233
  editable
8234
8234
  placeholder="15"
8235
8235
  value={settings.defaultFont?.size ?? ''}
8236
- options={[{ value: '', label: '15 (standard)' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
8236
+ options={[{ value: '', label: '15 (standard)', closed: '15' }, ...FONT_SIZES.map(size => ({ value: size, label: size.replace('px', '') }))]}
8237
8237
  onChange={raw => {
8238
8238
  const size = /^\d+(\.\d+)?$/.test(raw) ? `${raw}px` : raw
8239
8239
  if (size && !/^\d+(\.\d+)?(px|pt|em|rem|%)$/.test(size)) return
@@ -8248,12 +8248,12 @@ export default function DevMailPage() {
8248
8248
  <div className={styles.fontAdd}>
8249
8249
  <MailSelect
8250
8250
  ariaLabel="Line spacing"
8251
- prefix="↕"
8251
+ prefix={GLYPH.spacing}
8252
8252
  editable
8253
8253
  placeholder={String(DEFAULT_LINE_SPACING)}
8254
8254
  value={settings.defaultFont?.lineSpacing ? String(settings.defaultFont.lineSpacing) : ''}
8255
8255
  options={[
8256
- { value: '', label: `${DEFAULT_LINE_SPACING} (standard)` },
8256
+ { value: '', label: `${DEFAULT_LINE_SPACING} (standard)`, closed: String(DEFAULT_LINE_SPACING) },
8257
8257
  ...LINE_SPACINGS.filter(spacing => spacing !== DEFAULT_LINE_SPACING).map(spacing => ({ value: String(spacing), label: String(spacing) })),
8258
8258
  ]}
8259
8259
  onChange={raw => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@novacraft-engineering/mailbox",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
5
5
  "keywords": [
6
6
  "webmail",