@aryacode/dsh-aryacode-polish 0.11.2 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.1] - 2026-10-28
4
+
5
+ ### Fixed
6
+
7
+ - The Hero list now only appears when the workspace chip is open, and it paints a
8
+ card of its own instead of an unstyled list floating over the Hero copy.
9
+
10
+ ## [0.12.0] - 2026-10-28
11
+
12
+ ### Added
13
+
14
+ - **Colour and weight per workspace.** Right-click a workspace → "Colour and
15
+ weight…" paints it one of six tones (slate, blue, green, amber, rose, violet) or
16
+ none, and can make its label bold. The choice is stored under
17
+ `aryacode-polish:workspace-appearance` in the app's own storage, so it survives a
18
+ restart, and a broken or missing store degrades to the plain tree instead of
19
+ throwing.
20
+ - **The same workspaces in the Hero picker.** The blank-session Hero now offers this
21
+ bundle's workspace list (tone dots, bold labels, session counts) instead of the
22
+ stock picker, through `conversation.hero.workspace` at priority -1 — same data,
23
+ same colours, one switch.
24
+
3
25
  ## [0.11.2] - 2026-10-28
4
26
 
5
27
  ### Verified
@@ -382,6 +382,82 @@ export function moveBefore(list, movingId, beforeId) {
382
382
  return next
383
383
  }
384
384
 
385
+ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
386
+
387
+ export const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
388
+
389
+ export const TONE_COLOR = {
390
+ slate: '#8a8f98',
391
+ blue: '#4a72d0',
392
+ green: '#2f9e6b',
393
+ amber: '#c8892f',
394
+ rose: '#c2496a',
395
+ violet: '#7a5cc4',
396
+ }
397
+
398
+ export function appearanceOf(map, workspaceId) {
399
+ const entry = (map && map[workspaceId]) || {}
400
+ return {
401
+ tone: TONE_COLOR[entry.tone] ? entry.tone : undefined,
402
+ strong: entry.strong === true,
403
+ }
404
+ }
405
+
406
+ export function withAppearance(map, workspaceId, patch) {
407
+ const next = Object.assign({}, map || {})
408
+ const merged = Object.assign({}, appearanceOf(next, workspaceId), patch || {})
409
+ if (!merged.tone && merged.strong !== true) delete next[workspaceId]
410
+ else next[workspaceId] = { tone: merged.tone, strong: merged.strong === true }
411
+ return next
412
+ }
413
+
414
+ export function readAppearance(storage) {
415
+ try {
416
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
417
+ if (!store) return {}
418
+ const raw = store.getItem(LOOK_KEY)
419
+ if (!raw) return {}
420
+ const parsed = JSON.parse(raw)
421
+ return parsed && typeof parsed === 'object' ? parsed : {}
422
+ } catch {
423
+ return {}
424
+ }
425
+ }
426
+
427
+ export function writeAppearance(map, storage) {
428
+ try {
429
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
430
+ if (!store) return
431
+ store.setItem(LOOK_KEY, JSON.stringify(map || {}))
432
+ } catch {
433
+ /* a locked-down webview must not break the tree */
434
+ }
435
+ }
436
+
437
+ const WS_TONE = {
438
+ flex: 'none',
439
+ width: '6px',
440
+ height: '6px',
441
+ borderRadius: '50%',
442
+ boxShadow: '0 0 0 2px var(--dsw-alias-bg-base, transparent)',
443
+ }
444
+
445
+ const WS_SWATCH = {
446
+ width: '18px',
447
+ height: '18px',
448
+ borderRadius: '50%',
449
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
450
+ cursor: 'pointer',
451
+ padding: 0,
452
+ }
453
+
454
+ const WS_PAINT_ROW = {
455
+ display: 'flex',
456
+ alignItems: 'center',
457
+ gap: '6px',
458
+ padding: '6px 8px',
459
+ }
460
+
385
461
  const WS_FILTER = {
386
462
  margin: '2px 2px 6px',
387
463
  padding: '4px 8px',
@@ -447,6 +523,14 @@ export function BetterWorkspace(props) {
447
523
  const [hover, setHover] = React.useState(null)
448
524
  const [drag, setDrag] = React.useState(null)
449
525
  const [dropping, setDropping] = React.useState(null)
526
+ const [look, setLook] = React.useState(() => readAppearance())
527
+ const paintLook = (workspaceId, patch) => {
528
+ setLook((prev) => {
529
+ const next = withAppearance(prev, workspaceId, patch)
530
+ writeAppearance(next)
531
+ return next
532
+ })
533
+ }
450
534
  const filterRef = React.useRef(null)
451
535
 
452
536
  const workspaceState = useLive(services.workspacesLive)
@@ -588,6 +672,15 @@ export function BetterWorkspace(props) {
588
672
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
589
673
  },
590
674
  }, 'Copy path'),
675
+ React.createElement('button', {
676
+ type: 'button',
677
+ 'data-polish-ws-appearance': 'open',
678
+ style: WS_MENU_ITEM,
679
+ onClick: () => {
680
+ const workspace = menu.workspace
681
+ setMenu({ kind: 'paint', workspace, anchor: menu.anchor })
682
+ },
683
+ }, 'Colour and weight…'),
591
684
  React.createElement('button', {
592
685
  type: 'button',
593
686
  style: Object.assign({}, WS_MENU_ITEM, { color: 'var(--dsw-alias-label-error, #d92d20)' }),
@@ -604,6 +697,44 @@ export function BetterWorkspace(props) {
604
697
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
605
698
  : null
606
699
 
700
+ const paintMenu = menu && menu.kind === 'paint'
701
+ ? (() => {
702
+ const workspace = menu.workspace
703
+ const current = appearanceOf(look, workspace.workspaceId)
704
+ return React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
705
+ React.createElement('div', { style: WS_PAINT_ROW },
706
+ TONE_ORDER.map((tone) => React.createElement('button', {
707
+ key: tone,
708
+ type: 'button',
709
+ 'data-polish-tone': tone,
710
+ 'aria-label': 'Colour ' + tone,
711
+ title: tone,
712
+ style: Object.assign({}, WS_SWATCH, {
713
+ background: TONE_COLOR[tone],
714
+ boxShadow: current.tone === tone
715
+ ? '0 0 0 1.5px var(--dsw-alias-label-secondary, #61666b)'
716
+ : undefined,
717
+ }),
718
+ onClick: () => paintLook(workspace.workspaceId, { tone }),
719
+ })),
720
+ React.createElement('button', {
721
+ key: 'none',
722
+ type: 'button',
723
+ 'data-polish-tone': 'none',
724
+ 'aria-label': 'No colour',
725
+ title: 'No colour',
726
+ style: Object.assign({}, WS_SWATCH, { background: 'transparent' }),
727
+ onClick: () => paintLook(workspace.workspaceId, { tone: undefined }),
728
+ })),
729
+ React.createElement('button', {
730
+ type: 'button',
731
+ 'data-polish-ws-strong': current.strong ? 'on' : 'off',
732
+ style: WS_MENU_ITEM,
733
+ onClick: () => paintLook(workspace.workspaceId, { strong: !current.strong }),
734
+ }, current.strong ? 'Weight: bold ✓' : 'Weight: normal'))
735
+ })()
736
+ : null
737
+
607
738
  const sessionMenu = menu && menu.kind === 'session'
608
739
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
609
740
  entries.map((entry, index) => React.createElement(RowBoundary, { key: (entry.options && entry.options.id) || index },
@@ -724,7 +855,16 @@ export function BetterWorkspace(props) {
724
855
  },
725
856
  React.createElement(CaretGlyph, { open }),
726
857
  React.createElement(FolderGlyph),
727
- React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
858
+ look[workspace.workspaceId] && appearanceOf(look, workspace.workspaceId).tone
859
+ ? React.createElement('span', {
860
+ style: Object.assign({}, WS_TONE, { background: TONE_COLOR[appearanceOf(look, workspace.workspaceId).tone] }),
861
+ })
862
+ : null,
863
+ React.createElement('span', {
864
+ style: Object.assign({}, WS_TITLE, {
865
+ fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
866
+ }),
867
+ }, workspaceLabel(workspace)),
728
868
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
729
869
  hovered || focused
730
870
  ? React.createElement('button', {
@@ -806,9 +946,81 @@ export function BetterWorkspace(props) {
806
946
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
807
947
  : null,
808
948
  sessionMenu,
949
+ paintMenu,
809
950
  workspaceMenu)
810
951
  }
811
952
 
953
+ const WS_HERO = {
954
+ display: 'flex',
955
+ flexDirection: 'column',
956
+ gap: '1px',
957
+ minWidth: '220px',
958
+ maxHeight: '320px',
959
+ overflowY: 'auto',
960
+ padding: '4px',
961
+ }
962
+
963
+ const WS_HERO_TITLE = {
964
+ fontSize: 'calc(' + "var(--dsh-content-font-size-secondary, 13px)" + ' - 2px)',
965
+ letterSpacing: '0.02em',
966
+ textTransform: 'uppercase',
967
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
968
+ padding: '4px 6px 2px',
969
+ }
970
+
971
+ /**
972
+ * The Hero's workspace picker. Same data and same colours as the sidebar tree,
973
+ * reachable before a session exists — the Hero slot wants a list that calls
974
+ * `onPick(workspaceId)`, so this is a flat list rather than the tree.
975
+ */
976
+ export function HeroWorkspacePicker(props) {
977
+ const services = (props && props.services) || {}
978
+ const workspaceState = useLive(services.workspacesLive)
979
+ const look = React.useMemo(() => readAppearance(), [])
980
+ const list = (workspaceState && (workspaceState.items || workspaceState.workspaces)) || []
981
+ // The slot hands over `open`: the chip is closed most of the time, and rendering
982
+ // the list unconditionally left it floating over the Hero copy.
983
+ if (!props || props.open !== true || list.length === 0) return null
984
+ const anchor = props.anchorRef && props.anchorRef.current
985
+ const spot = anchor && anchor.getBoundingClientRect
986
+ ? (() => {
987
+ const box = anchor.getBoundingClientRect()
988
+ return {
989
+ position: 'fixed',
990
+ top: Math.round(box.bottom + 6) + 'px',
991
+ left: Math.max(8, Math.round(box.left)) + 'px',
992
+ zIndex: 1200,
993
+ }
994
+ })()
995
+ : {}
996
+ return ReactDOM.createPortal(React.createElement('div', { 'data-polish-hero': 'true', style: Object.assign({}, WS_MENU, WS_HERO, spot, { maxHeight: '320px' }) },
997
+ React.createElement('div', { style: WS_HERO_TITLE }, 'Workspaces'),
998
+ list.map((workspace) => {
999
+ const looks = appearanceOf(look, workspace.workspaceId)
1000
+ return React.createElement('button', {
1001
+ key: workspace.workspaceId,
1002
+ type: 'button',
1003
+ 'data-polish-hero-row': workspace.workspaceId,
1004
+ style: Object.assign({}, WS_ROW, {
1005
+ fontWeight: looks.strong ? 600 : 500,
1006
+ background: 'transparent',
1007
+ border: 'none',
1008
+ textAlign: 'left',
1009
+ }),
1010
+ onClick: () => {
1011
+ if (props.onPick) props.onPick(workspace.workspaceId)
1012
+ if (props.onClose) props.onClose()
1013
+ },
1014
+ },
1015
+ React.createElement(FolderGlyph),
1016
+ looks.tone
1017
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1018
+ : null,
1019
+ React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1020
+ React.createElement('span', { style: WS_COUNT }, String((workspace.sessionIds || []).length)))
1021
+ })), document.body)
1022
+ }
1023
+
812
1024
  /**
813
1025
  * Add and dispose the seat with the switch. A `single` slot renders its LOWEST
814
1026
  * priority occupant, so presence is the only way to stand aside: without this the
@@ -816,6 +1028,7 @@ export function BetterWorkspace(props) {
816
1028
  */
817
1029
  export function installWorkspaceSeat(ctx, configForms, extras = {}) {
818
1030
  let dispose
1031
+ let disposeHero
819
1032
  let unlisten
820
1033
  const scope = () => {
821
1034
  try {
@@ -868,6 +1081,13 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
868
1081
  priority: -1,
869
1082
  inject: () => ({ services }),
870
1083
  }, BetterWorkspace))
1084
+ // The Hero picker is its own single slot, so it needs its own -1 seat;
1085
+ // both live and die with the one switch.
1086
+ disposeHero = seated.slots.inject('conversation.hero.workspace', () => seated.slots.register({
1087
+ name: 'conversation.hero.workspace',
1088
+ priority: -1,
1089
+ inject: () => ({ services }),
1090
+ }, HeroWorkspacePicker))
871
1091
  },
872
1092
  )
873
1093
  } catch (error) {
@@ -880,6 +1100,12 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
880
1100
  install()
881
1101
  return
882
1102
  }
1103
+ if (typeof disposeHero === 'function') {
1104
+ try {
1105
+ disposeHero()
1106
+ } catch {}
1107
+ }
1108
+ disposeHero = undefined
883
1109
  if (typeof dispose === 'function') {
884
1110
  try {
885
1111
  dispose()
@@ -897,6 +1123,7 @@ export function installWorkspaceSeat(ctx, configForms, extras = {}) {
897
1123
  }
898
1124
  return () => {
899
1125
  if (typeof unlisten === 'function') unlisten()
1126
+ if (typeof disposeHero === 'function') disposeHero()
900
1127
  if (typeof dispose === 'function') dispose()
901
1128
  }
902
1129
  }
@@ -93,3 +93,24 @@ So the tree refuses with "A session stays in its workspace; drag it within that
93
93
  workspace to reorder." rather than attempting a call that throws. If the Host ever
94
94
  exposes attach/detach, the gesture becomes a one-line change here — the drag payload
95
95
  already carries `{ kind: 'session', id, workspaceId }`.
96
+
97
+ ## Colour and weight
98
+
99
+ Right-click a workspace → **Colour and weight…** opens a six-tone palette (slate, blue,
100
+ green, amber, rose, violet) plus "none", and toggles a bold label. The choice is kept in
101
+ the app's own storage under `aryacode-polish:workspace-appearance.v1` as
102
+ `{ [workspaceId]: { tone, strong } }`, so it survives a restart without asking the Host
103
+ to remember anything. `readAppearance`/`writeAppearance` swallow every failure
104
+ (missing storage, quota, a locked-down webview), so a broken store costs the colours
105
+ and nothing else. `appearanceOf` ignores a tone it does not know, which keeps an old
106
+ entry from breaking the row if the palette ever changes.
107
+
108
+ ## The Hero picker
109
+
110
+ The blank-session Hero's workspace chip is the same list, not a second implementation:
111
+ `conversation.hero.workspace` is a `single` slot at the root, so this bundle shadows it
112
+ at `priority: -1` beside `sidebar.workspaces` and both seats come and go with the one
113
+ switch. The slot hands over `{ open, anchorRef, selectedId, onPick, onClose }`: the card
114
+ is portalled to the anchor's bottom-left only while `open` is true (an unconditional
115
+ list floated over the Hero copy), it shows the same tone dots and bold labels as the
116
+ sidebar, and picking a row calls `onPick(workspaceId)` then `onClose()`.
package/lib/client.js CHANGED
@@ -53,7 +53,7 @@ window.__ModuleLoader__.load({
53
53
  ]
54
54
  const ROW_ID = "polish"
55
55
  const ROW_KEY = "@aryacode/dsh-aryacode-polish" + '#' + ROW_ID
56
- const CLIENT_VERSION = "0.11.2"
56
+ const CLIENT_VERSION = "0.12.1"
57
57
 
58
58
  /* html2canvas 1.4.1 — MIT, vendored from vendor/html2canvas.min.js.
59
59
  * A UMD build, so it assigns itself to globalThis; face.js reads it lazily. */
@@ -583,6 +583,82 @@ window.__ModuleLoader__.load({
583
583
  return next
584
584
  }
585
585
 
586
+ const LOOK_KEY = 'aryacode-polish:workspace-appearance.v1'
587
+
588
+ const TONE_ORDER = ['slate', 'blue', 'green', 'amber', 'rose', 'violet']
589
+
590
+ const TONE_COLOR = {
591
+ slate: '#8a8f98',
592
+ blue: '#4a72d0',
593
+ green: '#2f9e6b',
594
+ amber: '#c8892f',
595
+ rose: '#c2496a',
596
+ violet: '#7a5cc4',
597
+ }
598
+
599
+ function appearanceOf(map, workspaceId) {
600
+ const entry = (map && map[workspaceId]) || {}
601
+ return {
602
+ tone: TONE_COLOR[entry.tone] ? entry.tone : undefined,
603
+ strong: entry.strong === true,
604
+ }
605
+ }
606
+
607
+ function withAppearance(map, workspaceId, patch) {
608
+ const next = Object.assign({}, map || {})
609
+ const merged = Object.assign({}, appearanceOf(next, workspaceId), patch || {})
610
+ if (!merged.tone && merged.strong !== true) delete next[workspaceId]
611
+ else next[workspaceId] = { tone: merged.tone, strong: merged.strong === true }
612
+ return next
613
+ }
614
+
615
+ function readAppearance(storage) {
616
+ try {
617
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
618
+ if (!store) return {}
619
+ const raw = store.getItem(LOOK_KEY)
620
+ if (!raw) return {}
621
+ const parsed = JSON.parse(raw)
622
+ return parsed && typeof parsed === 'object' ? parsed : {}
623
+ } catch {
624
+ return {}
625
+ }
626
+ }
627
+
628
+ function writeAppearance(map, storage) {
629
+ try {
630
+ const store = storage || (typeof localStorage === 'undefined' ? undefined : localStorage)
631
+ if (!store) return
632
+ store.setItem(LOOK_KEY, JSON.stringify(map || {}))
633
+ } catch {
634
+ /* a locked-down webview must not break the tree */
635
+ }
636
+ }
637
+
638
+ const WS_TONE = {
639
+ flex: 'none',
640
+ width: '6px',
641
+ height: '6px',
642
+ borderRadius: '50%',
643
+ boxShadow: '0 0 0 2px var(--dsw-alias-bg-base, transparent)',
644
+ }
645
+
646
+ const WS_SWATCH = {
647
+ width: '18px',
648
+ height: '18px',
649
+ borderRadius: '50%',
650
+ border: '0.5px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))',
651
+ cursor: 'pointer',
652
+ padding: 0,
653
+ }
654
+
655
+ const WS_PAINT_ROW = {
656
+ display: 'flex',
657
+ alignItems: 'center',
658
+ gap: '6px',
659
+ padding: '6px 8px',
660
+ }
661
+
586
662
  const WS_FILTER = {
587
663
  margin: '2px 2px 6px',
588
664
  padding: '4px 8px',
@@ -648,6 +724,14 @@ window.__ModuleLoader__.load({
648
724
  const [hover, setHover] = React.useState(null)
649
725
  const [drag, setDrag] = React.useState(null)
650
726
  const [dropping, setDropping] = React.useState(null)
727
+ const [look, setLook] = React.useState(() => readAppearance())
728
+ const paintLook = (workspaceId, patch) => {
729
+ setLook((prev) => {
730
+ const next = withAppearance(prev, workspaceId, patch)
731
+ writeAppearance(next)
732
+ return next
733
+ })
734
+ }
651
735
  const filterRef = React.useRef(null)
652
736
 
653
737
  const workspaceState = useLive(services.workspacesLive)
@@ -789,6 +873,15 @@ window.__ModuleLoader__.load({
789
873
  try { await navigator.clipboard.writeText(menu.workspace.path) } catch {}
790
874
  },
791
875
  }, 'Copy path'),
876
+ React.createElement('button', {
877
+ type: 'button',
878
+ 'data-polish-ws-appearance': 'open',
879
+ style: WS_MENU_ITEM,
880
+ onClick: () => {
881
+ const workspace = menu.workspace
882
+ setMenu({ kind: 'paint', workspace, anchor: menu.anchor })
883
+ },
884
+ }, 'Colour and weight…'),
792
885
  React.createElement('button', {
793
886
  type: 'button',
794
887
  style: Object.assign({}, WS_MENU_ITEM, { color: 'var(--dsw-alias-label-error, #d92d20)' }),
@@ -805,6 +898,44 @@ window.__ModuleLoader__.load({
805
898
  }, armed && armed === menu.workspace.workspaceId ? 'Remove workspace?' : 'Remove workspace'))
806
899
  : null
807
900
 
901
+ const paintMenu = menu && menu.kind === 'paint'
902
+ ? (() => {
903
+ const workspace = menu.workspace
904
+ const current = appearanceOf(look, workspace.workspaceId)
905
+ return React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
906
+ React.createElement('div', { style: WS_PAINT_ROW },
907
+ TONE_ORDER.map((tone) => React.createElement('button', {
908
+ key: tone,
909
+ type: 'button',
910
+ 'data-polish-tone': tone,
911
+ 'aria-label': 'Colour ' + tone,
912
+ title: tone,
913
+ style: Object.assign({}, WS_SWATCH, {
914
+ background: TONE_COLOR[tone],
915
+ boxShadow: current.tone === tone
916
+ ? '0 0 0 1.5px var(--dsw-alias-label-secondary, #61666b)'
917
+ : undefined,
918
+ }),
919
+ onClick: () => paintLook(workspace.workspaceId, { tone }),
920
+ })),
921
+ React.createElement('button', {
922
+ key: 'none',
923
+ type: 'button',
924
+ 'data-polish-tone': 'none',
925
+ 'aria-label': 'No colour',
926
+ title: 'No colour',
927
+ style: Object.assign({}, WS_SWATCH, { background: 'transparent' }),
928
+ onClick: () => paintLook(workspace.workspaceId, { tone: undefined }),
929
+ })),
930
+ React.createElement('button', {
931
+ type: 'button',
932
+ 'data-polish-ws-strong': current.strong ? 'on' : 'off',
933
+ style: WS_MENU_ITEM,
934
+ onClick: () => paintLook(workspace.workspaceId, { strong: !current.strong }),
935
+ }, current.strong ? 'Weight: bold ✓' : 'Weight: normal'))
936
+ })()
937
+ : null
938
+
808
939
  const sessionMenu = menu && menu.kind === 'session'
809
940
  ? React.createElement(RowMenu, { anchor: menu.anchor, onClose: closeMenu },
810
941
  entries.map((entry, index) => React.createElement(RowBoundary, { key: (entry.options && entry.options.id) || index },
@@ -925,7 +1056,16 @@ window.__ModuleLoader__.load({
925
1056
  },
926
1057
  React.createElement(CaretGlyph, { open }),
927
1058
  React.createElement(FolderGlyph),
928
- React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1059
+ look[workspace.workspaceId] && appearanceOf(look, workspace.workspaceId).tone
1060
+ ? React.createElement('span', {
1061
+ style: Object.assign({}, WS_TONE, { background: TONE_COLOR[appearanceOf(look, workspace.workspaceId).tone] }),
1062
+ })
1063
+ : null,
1064
+ React.createElement('span', {
1065
+ style: Object.assign({}, WS_TITLE, {
1066
+ fontWeight: appearanceOf(look, workspace.workspaceId).strong ? 600 : undefined,
1067
+ }),
1068
+ }, workspaceLabel(workspace)),
929
1069
  React.createElement('span', { style: WS_COUNT }, String(sessions.length)),
930
1070
  hovered || focused
931
1071
  ? React.createElement('button', {
@@ -1007,9 +1147,81 @@ window.__ModuleLoader__.load({
1007
1147
  ? React.createElement('div', { key: 'count', style: WS_COUNT_NOTE }, String(shownCount) + ' of ' + String(totalCount) + ' sessions')
1008
1148
  : null,
1009
1149
  sessionMenu,
1150
+ paintMenu,
1010
1151
  workspaceMenu)
1011
1152
  }
1012
1153
 
1154
+ const WS_HERO = {
1155
+ display: 'flex',
1156
+ flexDirection: 'column',
1157
+ gap: '1px',
1158
+ minWidth: '220px',
1159
+ maxHeight: '320px',
1160
+ overflowY: 'auto',
1161
+ padding: '4px',
1162
+ }
1163
+
1164
+ const WS_HERO_TITLE = {
1165
+ fontSize: 'calc(' + "var(--dsh-content-font-size-secondary, 13px)" + ' - 2px)',
1166
+ letterSpacing: '0.02em',
1167
+ textTransform: 'uppercase',
1168
+ color: 'var(--dsw-alias-label-quaternary, #8a8a8a)',
1169
+ padding: '4px 6px 2px',
1170
+ }
1171
+
1172
+ /**
1173
+ * The Hero's workspace picker. Same data and same colours as the sidebar tree,
1174
+ * reachable before a session exists — the Hero slot wants a list that calls
1175
+ * `onPick(workspaceId)`, so this is a flat list rather than the tree.
1176
+ */
1177
+ function HeroWorkspacePicker(props) {
1178
+ const services = (props && props.services) || {}
1179
+ const workspaceState = useLive(services.workspacesLive)
1180
+ const look = React.useMemo(() => readAppearance(), [])
1181
+ const list = (workspaceState && (workspaceState.items || workspaceState.workspaces)) || []
1182
+ // The slot hands over `open`: the chip is closed most of the time, and rendering
1183
+ // the list unconditionally left it floating over the Hero copy.
1184
+ if (!props || props.open !== true || list.length === 0) return null
1185
+ const anchor = props.anchorRef && props.anchorRef.current
1186
+ const spot = anchor && anchor.getBoundingClientRect
1187
+ ? (() => {
1188
+ const box = anchor.getBoundingClientRect()
1189
+ return {
1190
+ position: 'fixed',
1191
+ top: Math.round(box.bottom + 6) + 'px',
1192
+ left: Math.max(8, Math.round(box.left)) + 'px',
1193
+ zIndex: 1200,
1194
+ }
1195
+ })()
1196
+ : {}
1197
+ return ReactDOM.createPortal(React.createElement('div', { 'data-polish-hero': 'true', style: Object.assign({}, WS_MENU, WS_HERO, spot, { maxHeight: '320px' }) },
1198
+ React.createElement('div', { style: WS_HERO_TITLE }, 'Workspaces'),
1199
+ list.map((workspace) => {
1200
+ const looks = appearanceOf(look, workspace.workspaceId)
1201
+ return React.createElement('button', {
1202
+ key: workspace.workspaceId,
1203
+ type: 'button',
1204
+ 'data-polish-hero-row': workspace.workspaceId,
1205
+ style: Object.assign({}, WS_ROW, {
1206
+ fontWeight: looks.strong ? 600 : 500,
1207
+ background: 'transparent',
1208
+ border: 'none',
1209
+ textAlign: 'left',
1210
+ }),
1211
+ onClick: () => {
1212
+ if (props.onPick) props.onPick(workspace.workspaceId)
1213
+ if (props.onClose) props.onClose()
1214
+ },
1215
+ },
1216
+ React.createElement(FolderGlyph),
1217
+ looks.tone
1218
+ ? React.createElement('span', { style: Object.assign({}, WS_TONE, { background: TONE_COLOR[looks.tone] }) })
1219
+ : null,
1220
+ React.createElement('span', { style: WS_TITLE }, workspaceLabel(workspace)),
1221
+ React.createElement('span', { style: WS_COUNT }, String((workspace.sessionIds || []).length)))
1222
+ })), document.body)
1223
+ }
1224
+
1013
1225
  /**
1014
1226
  * Add and dispose the seat with the switch. A `single` slot renders its LOWEST
1015
1227
  * priority occupant, so presence is the only way to stand aside: without this the
@@ -1017,6 +1229,7 @@ window.__ModuleLoader__.load({
1017
1229
  */
1018
1230
  function installWorkspaceSeat(ctx, configForms, extras = {}) {
1019
1231
  let dispose
1232
+ let disposeHero
1020
1233
  let unlisten
1021
1234
  const scope = () => {
1022
1235
  try {
@@ -1069,6 +1282,13 @@ window.__ModuleLoader__.load({
1069
1282
  priority: -1,
1070
1283
  inject: () => ({ services }),
1071
1284
  }, BetterWorkspace))
1285
+ // The Hero picker is its own single slot, so it needs its own -1 seat;
1286
+ // both live and die with the one switch.
1287
+ disposeHero = seated.slots.inject('conversation.hero.workspace', () => seated.slots.register({
1288
+ name: 'conversation.hero.workspace',
1289
+ priority: -1,
1290
+ inject: () => ({ services }),
1291
+ }, HeroWorkspacePicker))
1072
1292
  },
1073
1293
  )
1074
1294
  } catch (error) {
@@ -1081,6 +1301,12 @@ window.__ModuleLoader__.load({
1081
1301
  install()
1082
1302
  return
1083
1303
  }
1304
+ if (typeof disposeHero === 'function') {
1305
+ try {
1306
+ disposeHero()
1307
+ } catch {}
1308
+ }
1309
+ disposeHero = undefined
1084
1310
  if (typeof dispose === 'function') {
1085
1311
  try {
1086
1312
  dispose()
@@ -1098,6 +1324,7 @@ window.__ModuleLoader__.load({
1098
1324
  }
1099
1325
  return () => {
1100
1326
  if (typeof unlisten === 'function') unlisten()
1327
+ if (typeof disposeHero === 'function') disposeHero()
1101
1328
  if (typeof dispose === 'function') dispose()
1102
1329
  }
1103
1330
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aryacode/dsh-aryacode-polish",
3
- "version": "0.11.2",
3
+ "version": "0.12.1",
4
4
  "description": "AryaCode Polish: one install for the small things that make AryaCode feel finished. One Plugins row, one switch per quality-of-life feature.",
5
5
  "keywords": [
6
6
  "dsh",