Merge nucleic/mellow-opal-heron-qmvv into dev
This commit is contained in:
@@ -99,6 +99,16 @@ struct IntelligenceSlider: View {
|
||||
/// Draft text from the mounting composer. Its changes pause the shimmer while the user is
|
||||
/// typing; an empty draft or three seconds without another edit lets it run continuously.
|
||||
var composerText: String = ""
|
||||
/// The route this level currently resolves to ("Sonnet 4.6, reasoning high"), spoken as part
|
||||
/// of the accessibility value. Sighted users read it in the subtext below the rail; folding it
|
||||
/// in here means assistive tech doesn't have to go find a separate element to learn what a
|
||||
/// level will actually run. `nil` while no prompt-specific decision exists yet.
|
||||
var routeDescription: String? = nil
|
||||
/// Whether that route is still being classified — spoken in place of a stale choice.
|
||||
var routePending: Bool = false
|
||||
/// Why the rail can't be used, when `enabled` is false (no connected route, usage limits).
|
||||
/// Dimming alone conveys this to sighted users; this carries the same fact to everyone else.
|
||||
var unavailableReason: String? = nil
|
||||
/// During a drag, reports the detent under the pointer before the binding commits on release.
|
||||
/// `nil` means the gesture ended and the committed binding is authoritative again.
|
||||
var onLevelPreviewChanged: ((IntelligenceLevel?) -> Void)? = nil
|
||||
@@ -108,6 +118,8 @@ struct IntelligenceSlider: View {
|
||||
|
||||
@Environment(\.appPalette) private var palette
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
@Environment(\.colorSchemeContrast) private var colorSchemeContrast
|
||||
/// Non-nil while the nub is being dragged — the displayed position, magnetized toward
|
||||
/// detents. The binding only commits on release.
|
||||
@State private var dragFraction: Double?
|
||||
@@ -130,6 +142,10 @@ struct IntelligenceSlider: View {
|
||||
/// shimmer's first phase and any level animation can sweep up the control's initial
|
||||
/// layout and visibly slide it into place on window restore.
|
||||
@State private var settled = false
|
||||
/// Set when the user themselves moves the rail, so the route that lands a moment later is
|
||||
/// announced. Route changes the user didn't cause — the classifier re-running as they type —
|
||||
/// must stay silent, or VoiceOver would narrate the subtext throughout composition.
|
||||
@State private var announcesNextRoute = false
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
private let trackHeight: CGFloat = 8
|
||||
@@ -142,6 +158,10 @@ struct IntelligenceSlider: View {
|
||||
/// Orchestra activates only near its dot, protecting Max despite the intentionally compact gap.
|
||||
private let orchestraActivationProgress = 0.90
|
||||
private let orchestraRumbleSpacing = 0.16
|
||||
/// Click radius around the Orchestra node — sized to the midpoint of the Max → Orchestra gap
|
||||
/// rather than to the 2pt resting dot, so imprecise pointing still lands it without taking a
|
||||
/// single pixel that would otherwise have gone to Max.
|
||||
private let orchestraClickRadius: CGFloat = 12
|
||||
private let clickSlop: CGFloat = 3
|
||||
/// The nub center's travel inset from the track ends (half the nub, so it never
|
||||
/// overhangs the capsule).
|
||||
@@ -173,44 +193,155 @@ struct IntelligenceSlider: View {
|
||||
private var isInteractive: Bool { enabled }
|
||||
private var isBright: Bool { enabled || orchestraTier != nil }
|
||||
|
||||
/// At rest the rail's structure — track, unreached detents, the dormant constellation — is
|
||||
/// deliberately faint decoration that sits well under the 3:1 non-text contrast floor. Either
|
||||
/// contrast accommodation raises all of it to a legible weight and pins the nub on screen, so
|
||||
/// the level stays readable without hunting for the fill's end.
|
||||
private var highContrast: Bool {
|
||||
reduceTransparency || colorSchemeContrast == .increased
|
||||
}
|
||||
|
||||
/// Picks between the resting opacity and its high-contrast counterpart.
|
||||
private func contrasted(_ base: Double, _ raised: Double) -> Double {
|
||||
highContrast ? raised : base
|
||||
}
|
||||
|
||||
// MARK: - Accessibility
|
||||
|
||||
/// Orchestra occupies one stop past Max on the represented slider. It is a distinct backend
|
||||
/// mode rather than a sixth capability level, but the rail is a single focusable control and
|
||||
/// dragging is the only other way to reach it — so an assistive-tech user has to be able to
|
||||
/// increment onto it, or the mode is simply unreachable for them.
|
||||
private var accessibilityStopCount: Int {
|
||||
IntelligenceLevel.allCases.count + (orchestraAvailable ? 1 : 0)
|
||||
}
|
||||
|
||||
private var orchestraAccessibilityStop: Double {
|
||||
Double(IntelligenceLevel.allCases.count + 1)
|
||||
}
|
||||
|
||||
private var accessibilitySliderValue: Double {
|
||||
if orchestraAvailable, orchestraTier != nil { return orchestraAccessibilityStop }
|
||||
return Double(level.rawValue)
|
||||
}
|
||||
|
||||
private func commitAccessibilityStop(_ raw: Double) {
|
||||
let stop = Int(raw.rounded())
|
||||
if orchestraAvailable, stop == IntelligenceLevel.allCases.count + 1 {
|
||||
setOrchestraTier(.orchestra)
|
||||
return
|
||||
}
|
||||
guard let next = IntelligenceLevel(rawValue: stop) else { return }
|
||||
setLevel(next)
|
||||
}
|
||||
|
||||
/// Everything the rail's dimming and subtext convey visually, spoken: the stop, whether it
|
||||
/// carries orchestration, and what it will actually run. Carried on the *value* rather than
|
||||
/// the hint so it survives the disabled state, where hints commonly go unread.
|
||||
private var accessibilityValueText: String {
|
||||
var parts = [displayedName]
|
||||
if displayedOrchestraTier != nil { parts.append("multi-agent orchestration") }
|
||||
if !isInteractive {
|
||||
parts.append(unavailableReason ?? "no route available")
|
||||
} else if routePending {
|
||||
parts.append("choosing model and effort")
|
||||
} else if let routeDescription {
|
||||
parts.append("routing to \(routeDescription)")
|
||||
}
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
|
||||
private var accessibilityHintText: String {
|
||||
guard isInteractive else {
|
||||
return unavailableReason ?? "No route is available for this level right now."
|
||||
}
|
||||
var hint = "Sets how much model capability this chat gets. "
|
||||
+ "Higher levels give more thorough results and cost more."
|
||||
if orchestraAvailable {
|
||||
hint += " The stop past Max is Orchestra, which fans work out to parallel subagents."
|
||||
}
|
||||
return hint
|
||||
}
|
||||
|
||||
/// Phrased for every input method rather than for the pointer — the same tooltip reaches
|
||||
/// keyboard users through the control's accessibility help.
|
||||
private var helpText: String {
|
||||
guard isInteractive else {
|
||||
let reason = unavailableReason ?? "no route is available right now"
|
||||
return "Intelligence: \(displayedName) — \(reason)"
|
||||
}
|
||||
var text = "Intelligence: \(displayedName) — higher levels route to more capable models "
|
||||
+ "and cost more. Use the arrow keys, or drag the rail."
|
||||
if orchestraAvailable {
|
||||
text += " The node past Max is Orchestra: parallel subagents."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
track
|
||||
.frame(width: Self.preferredWidth, height: controlHeight)
|
||||
.opacity(isBright ? 1 : 0.5)
|
||||
.focusable(isInteractive)
|
||||
// Keep arrow-key adjustment without macOS drawing its persistent blue focus
|
||||
// rectangle around this custom-shaped control after a pointer click.
|
||||
// rectangle around this custom-shaped control after a pointer click. The rail
|
||||
// draws its own ring instead (see `track`) — suppressing the system one without
|
||||
// replacing it would leave keyboard focus with no visible indicator at all.
|
||||
.focusEffectDisabled()
|
||||
.focused($focused)
|
||||
.onMoveCommand { direction in
|
||||
guard isInteractive else { return }
|
||||
// Vertical arrows adjust a horizontal slider on macOS too, so honor all four.
|
||||
switch direction {
|
||||
case .left: step(-1)
|
||||
case .right: step(+1)
|
||||
case .left, .down: step(-1)
|
||||
case .right, .up: step(+1)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
.onKeyPress(.home) {
|
||||
guard isInteractive else { return .ignored }
|
||||
setLevel(.quick)
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.end) {
|
||||
guard isInteractive else { return .ignored }
|
||||
if orchestraAvailable {
|
||||
setOrchestraTier(.orchestra)
|
||||
} else {
|
||||
setLevel(.max)
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
.accessibilityRepresentation {
|
||||
// The value, hint, and identifier are attached inside the representation so they
|
||||
// land on the substituted element itself; applied outside they describe a view
|
||||
// the accessibility tree has already replaced.
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: { Double(level.rawValue) },
|
||||
set: {
|
||||
guard let next = IntelligenceLevel(rawValue: Int($0.rounded())) else {
|
||||
return
|
||||
}
|
||||
setLevel(next)
|
||||
}
|
||||
get: { accessibilitySliderValue },
|
||||
set: { commitAccessibilityStop($0) }
|
||||
),
|
||||
in: 1...Double(IntelligenceLevel.allCases.count), step: 1
|
||||
in: 1...Double(accessibilityStopCount), step: 1
|
||||
) {
|
||||
Text("Intelligence")
|
||||
}
|
||||
.disabled(!isInteractive)
|
||||
.accessibilityValue(Text(accessibilityValueText))
|
||||
.accessibilityHint(Text(accessibilityHintText))
|
||||
.accessibilityIdentifier("intelligence-slider")
|
||||
}
|
||||
.help(helpText)
|
||||
.onChange(of: IntelligenceRouteAnnouncement(
|
||||
description: routeDescription, pending: routePending)
|
||||
) { _, route in
|
||||
// The route resolves a beat after the level does. Announce the one the user just
|
||||
// caused; consume the flag either way once the classifier settles, so a stale
|
||||
// request can't attach itself to a later, unrelated route change.
|
||||
guard announcesNextRoute, !route.pending else { return }
|
||||
announcesNextRoute = false
|
||||
guard let description = route.description else { return }
|
||||
AccessibilityNotification.Announcement("Routing to \(description)").post()
|
||||
}
|
||||
.accessibilityValue(Text(displayedName))
|
||||
.help(
|
||||
"Intelligence: \(displayedName) — drag the main rail for Quick through Max, "
|
||||
+ "then pull across the short gap to the Orchestra constellation.")
|
||||
.onAppear { DispatchQueue.main.async { settled = true } }
|
||||
.onDisappear {
|
||||
onLevelPreviewChanged?(nil)
|
||||
@@ -263,8 +394,12 @@ struct IntelligenceSlider: View {
|
||||
let deepGlowStrength = deepGlowProgress * (1.25 - 0.25 * deepGlowProgress)
|
||||
let animationEligible = settled && animationsSettled && isInteractive
|
||||
let reversingFromOrchestra = dragStartedFromOrchestra && dragLane == .orchestra
|
||||
let nubVisible = (dragLane != nil || hoveringTrack)
|
||||
// Keyboard focus has to show the nub: it is this control's only position indicator,
|
||||
// and the system focus ring is suppressed above. High contrast pins it on for the
|
||||
// same reason — the fill's end is otherwise the only cue to where the level sits.
|
||||
let nubVisible = (dragLane != nil || hoveringTrack || focused || highContrast)
|
||||
&& (activeTier == nil || reversingFromOrchestra)
|
||||
let ringWidth = orchestraAvailable ? orchestraGuideWidth : mainRailWidth
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
// A faint capsule underneath hints that the control continues past Max.
|
||||
@@ -272,7 +407,7 @@ struct IntelligenceSlider: View {
|
||||
// so Orchestra reads as an extension rather than a sixth point on the rail.
|
||||
if orchestraAvailable {
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
.fill(Color.secondary.opacity(contrasted(0.15, 0.34)))
|
||||
.frame(width: orchestraGuideWidth, height: trackHeight)
|
||||
.position(
|
||||
x: railLeading + orchestraGuideWidth / 2,
|
||||
@@ -280,15 +415,25 @@ struct IntelligenceSlider: View {
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.18))
|
||||
.fill(Color.secondary.opacity(contrasted(0.18, 0.42)))
|
||||
.frame(width: mainRailWidth, height: trackHeight)
|
||||
.position(x: railLeading + mainRailWidth / 2, y: trackY)
|
||||
|
||||
// The keyboard focus indicator. Traces the rail's own capsule geometry rather
|
||||
// than boxing the control, so focus reads as part of the rail's language.
|
||||
if focused && isInteractive {
|
||||
Capsule()
|
||||
.strokeBorder(palette.accent.opacity(0.9), lineWidth: 2)
|
||||
.frame(width: ringWidth + 6, height: nubSize + 6)
|
||||
.position(x: railLeading + ringWidth / 2, y: trackY)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
ForEach(IntelligenceLevel.allCases) { stop in
|
||||
let dotSize: CGFloat = stop == .max && orchestraAvailable ? 4 : 2
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(
|
||||
stop.fraction <= standardFraction ? 0 : 0.4))
|
||||
stop.fraction <= standardFraction ? 0 : contrasted(0.4, 0.85)))
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
.position(
|
||||
x: inset + (maxX - inset) * stop.fraction,
|
||||
@@ -340,6 +485,7 @@ struct IntelligenceSlider: View {
|
||||
value: nubStretch)
|
||||
.animation(PanelMotion.hover(reduceMotion), value: nubVisible)
|
||||
.animation(PanelMotion.hover(reduceMotion), value: hoveringTrack)
|
||||
.animation(PanelMotion.hover(reduceMotion), value: focused)
|
||||
.animation(PanelMotion.hover(reduceMotion), value: orchestraTier)
|
||||
.animation(
|
||||
dragLane == nil && settled
|
||||
@@ -361,7 +507,7 @@ struct IntelligenceSlider: View {
|
||||
.stroke(
|
||||
selected
|
||||
? AppTheme.orchestra.opacity(0.34)
|
||||
: Color.secondary.opacity(0.10),
|
||||
: Color.secondary.opacity(contrasted(0.10, 0.45)),
|
||||
style: StrokeStyle(
|
||||
lineWidth: selected ? 1 : 0.75,
|
||||
lineCap: .round))
|
||||
@@ -376,7 +522,8 @@ struct IntelligenceSlider: View {
|
||||
Circle()
|
||||
.fill(selected
|
||||
? AnyShapeStyle(AppTheme.orchestra)
|
||||
: AnyShapeStyle(Color.secondary.opacity(0.30)))
|
||||
: AnyShapeStyle(Color.secondary
|
||||
.opacity(contrasted(0.30, 0.70))))
|
||||
.frame(
|
||||
width: satelliteSize,
|
||||
height: satelliteSize)
|
||||
@@ -390,9 +537,10 @@ struct IntelligenceSlider: View {
|
||||
Circle()
|
||||
.fill(selected
|
||||
? AnyShapeStyle(AppTheme.orchestra)
|
||||
: AnyShapeStyle(Color.secondary.opacity(0.52)))
|
||||
: AnyShapeStyle(Color.secondary
|
||||
.opacity(contrasted(0.52, 0.90))))
|
||||
.overlay(Circle().strokeBorder(
|
||||
.white.opacity(selected ? 0.75 : 0.18),
|
||||
.white.opacity(selected ? 0.75 : contrasted(0.18, 0.45)),
|
||||
lineWidth: 0.5))
|
||||
.frame(
|
||||
width: selected ? 5.5 : 2,
|
||||
@@ -510,11 +658,13 @@ struct IntelligenceSlider: View {
|
||||
selection.fraction >= orchestraActivationProgress
|
||||
{
|
||||
let tier = IntelligenceOrchestraTier.orchestra
|
||||
if orchestraTier != tier { announcesNextRoute = true }
|
||||
orchestraTier = tier
|
||||
} else {
|
||||
let landed = selection.lane == .orchestra
|
||||
? IntelligenceLevel.max
|
||||
: IntelligenceSliderMath.nearestLevel(fraction: selection.fraction)
|
||||
if orchestraTier != nil || level != landed { announcesNextRoute = true }
|
||||
orchestraTier = nil
|
||||
level = landed
|
||||
}
|
||||
@@ -570,7 +720,7 @@ struct IntelligenceSlider: View {
|
||||
let dy = point.y - trackY
|
||||
let gap = orchestraX - maxX
|
||||
let dotDX = point.x - orchestraX
|
||||
if isClick, dotDX * dotDX + dy * dy <= 64 {
|
||||
if isClick, dotDX * dotDX + dy * dy <= orchestraClickRadius * orchestraClickRadius {
|
||||
return 1
|
||||
}
|
||||
guard gap > 0,
|
||||
@@ -607,12 +757,14 @@ struct IntelligenceSlider: View {
|
||||
guard next != level || orchestraTier != nil else { return }
|
||||
orchestraTier = nil
|
||||
level = next
|
||||
announcesNextRoute = true
|
||||
performLevelHaptic()
|
||||
}
|
||||
|
||||
private func setOrchestraTier(_ next: IntelligenceOrchestraTier) {
|
||||
guard orchestraTier != next else { return }
|
||||
orchestraTier = next
|
||||
announcesNextRoute = true
|
||||
performLevelHaptic()
|
||||
}
|
||||
|
||||
@@ -660,6 +812,13 @@ struct IntelligenceSlider: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two facts the rail's route announcement depends on, folded into one `onChange` identity
|
||||
/// so a resolution and the pending flag that precedes it can't race as separate observations.
|
||||
private struct IntelligenceRouteAnnouncement: Equatable {
|
||||
var description: String?
|
||||
var pending: Bool
|
||||
}
|
||||
|
||||
/// The route chosen for the current prompt, tucked directly below the Intelligence rail. The
|
||||
/// fixed footprint keeps model-name changes from shifting the surrounding composer controls.
|
||||
/// While the classifier is waiting/running, a quiet horizontal sweep replaces the stale choice.
|
||||
@@ -668,12 +827,20 @@ struct IntelligenceRoutePreview: View {
|
||||
var effort: String?
|
||||
var isPending: Bool
|
||||
|
||||
/// The row's height tracks its own text style: at accessibility text sizes a fixed 12pt
|
||||
/// frame clips the model name it exists to show.
|
||||
@ScaledMetric(relativeTo: .caption2) private var rowHeight: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .trailing) {
|
||||
if isPending {
|
||||
IntelligenceRoutePlaceholder()
|
||||
.transition(.opacity)
|
||||
// The stripes are pure skeleton — collapse them into one element that says
|
||||
// what they stand for, rather than leaving bare shapes in the tree.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Choosing model and effort")
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
} else if let model, let effort {
|
||||
Text(
|
||||
"\(ModelCatalog.displayName(model)) · "
|
||||
@@ -690,7 +857,7 @@ struct IntelligenceRoutePreview: View {
|
||||
}
|
||||
.font(.caption2.weight(.medium))
|
||||
.padding(.trailing, 16)
|
||||
.frame(width: IntelligenceSlider.preferredWidth, height: 12, alignment: .trailing)
|
||||
.frame(width: IntelligenceSlider.preferredWidth, height: rowHeight, alignment: .trailing)
|
||||
.animation(.easeInOut(duration: 0.18), value: isPending)
|
||||
}
|
||||
}
|
||||
@@ -708,16 +875,26 @@ private struct IntelligenceRoutePlaceholder: View {
|
||||
/// text arrives. Each stripe owns its own clipped sweep so light never bridges the gap.
|
||||
private struct IntelligenceRoutePlaceholderStripe: View {
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
@Environment(\.colorSchemeContrast) private var colorSchemeContrast
|
||||
@State private var sweep = false
|
||||
|
||||
var width: CGFloat
|
||||
|
||||
/// Tracks the row it stands in for, so the skeleton keeps the text's proportions at
|
||||
/// accessibility text sizes instead of shrinking away from it.
|
||||
@ScaledMetric(relativeTo: .caption2) private var stripeHeight: CGFloat = 7
|
||||
|
||||
private var highlightWidth: CGFloat { min(42, width * 0.44) }
|
||||
|
||||
private var fillOpacity: Double {
|
||||
reduceTransparency || colorSchemeContrast == .increased ? 0.30 : 0.12
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Capsule()
|
||||
.fill(Color.secondary.opacity(0.12))
|
||||
.frame(width: width, height: 7)
|
||||
.fill(Color.secondary.opacity(fillOpacity))
|
||||
.frame(width: width, height: stripeHeight)
|
||||
.overlay(alignment: .leading) {
|
||||
if !reduceMotion {
|
||||
Capsule()
|
||||
@@ -729,7 +906,7 @@ private struct IntelligenceRoutePlaceholderStripe: View {
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing))
|
||||
.frame(width: highlightWidth, height: 7)
|
||||
.frame(width: highlightWidth, height: stripeHeight)
|
||||
.offset(x: sweep ? width : -highlightWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,10 @@ struct NewChatComposer: View {
|
||||
orchestraAvailable: isControlProject,
|
||||
animationsSettled: layoutAnimationsEnabled,
|
||||
composerText: draft,
|
||||
routeDescription: routedAccessibilityDescription,
|
||||
routePending: routedPreviewPending,
|
||||
unavailableReason: routedRouteAvailable
|
||||
? nil : routedResolution.reason,
|
||||
onLevelPreviewChanged: { preview in
|
||||
if intelligenceLevelPreview != preview {
|
||||
intelligenceLevelPreview = preview
|
||||
@@ -742,6 +746,16 @@ struct NewChatComposer: View {
|
||||
return routedPreviewResolution.effort
|
||||
}
|
||||
|
||||
/// The resolved route spoken as part of the rail's own accessibility value. The subtext below
|
||||
/// the rail already states this for sighted users; carrying it on the control means assistive
|
||||
/// tech learns what a level will run without having to navigate to a separate element.
|
||||
private var routedAccessibilityDescription: String? {
|
||||
guard let model = routedPreviewModel, let effort = routedPreviewEffort else { return nil }
|
||||
return "\(ModelCatalog.displayName(model)), "
|
||||
+ "\(ModelCatalog.effortNoun(for: model)) "
|
||||
+ ModelCatalog.effortDisplayName(effort)
|
||||
}
|
||||
|
||||
/// Preserve the routed subtext's exact rendering and place a transparent menu hit target
|
||||
/// over it. The resolved words themselves become the control without acquiring button chrome,
|
||||
/// an indicator, or different spacing.
|
||||
|
||||
@@ -891,6 +891,14 @@ struct SessionDetailView: View {
|
||||
return effectiveEffort
|
||||
}
|
||||
|
||||
/// The resolved route spoken as part of the rail's accessibility value — the same fact the
|
||||
/// subtext below it renders, carried on the control so assistive tech needn't go find it.
|
||||
private var sessionRouteAccessibilityDescription: String {
|
||||
"\(ModelCatalog.displayName(sessionPreviewModel)), "
|
||||
+ "\(ModelCatalog.effortNoun(for: sessionPreviewModel)) "
|
||||
+ ModelCatalog.effortDisplayName(sessionPreviewEffort)
|
||||
}
|
||||
|
||||
/// The same fixed-footprint subtext as the new-chat composer, with a transparent Menu over
|
||||
/// the resolved words so becoming interactive adds no chrome, indicator, or spacing change.
|
||||
private var sessionRouteSelectionPreview: some View {
|
||||
@@ -2771,6 +2779,10 @@ struct SessionDetailView: View {
|
||||
orchestraAvailable: isControlProject,
|
||||
animationsSettled: !transcriptSettling,
|
||||
composerText: draft,
|
||||
routeDescription: sessionRouteAccessibilityDescription,
|
||||
unavailableReason: isArchived
|
||||
? "This chat is archived — its Intelligence level can't be changed."
|
||||
: nil,
|
||||
onLevelPreviewChanged: { preview in
|
||||
if sliderIntelligenceLevelPreview != preview {
|
||||
sliderIntelligenceLevelPreview = preview
|
||||
|
||||
+52
-3
@@ -1097,14 +1097,59 @@ Screens (parity targets from `Sources/NucleicApp` and `ios/.../Views`):
|
||||
| Home | `HomeView.swift`, activity grid, streaks, idea inbox (`TodoView.swift`) | Pure projections — data comes from host. |
|
||||
| Project view / Control panel | `ProjectView.swift`, `ControlPanelView.swift` | Container status/usage cards use `controlContainerStatus/Usage` wire data. |
|
||||
| Session detail + transcript | `SessionDetailView.swift` (2,407), `TranscriptRow.swift` (2,343), `HostExecCard.swift` | `ItemsRepeater` with incremental virtualization driven by `np_projection_apply` diffs; tool rows color-coded by risk; collapsible thinking. |
|
||||
| Composer | `NewChatComposer.swift`, `ChatInputField.swift`, `ComposerAttachments.swift` | Model/effort/Orchestra picker (SKU-routed backends), attachments. |
|
||||
| Composer | `NewChatComposer.swift`, `ChatInputField.swift`, `ComposerAttachments.swift`, `IntelligenceSlider.swift` | Model/effort/Orchestra picker (SKU-routed backends), attachments. The Intelligence rail is custom-drawn — its accessibility contract must be ported explicitly (§7.1). |
|
||||
| Approvals | approval cards in session view; `ApprovalCardView` (iOS) | Allow / deny / modify-and-allow / always-allow-in-session; first-responder-wins collapse on `approvalResolved`. |
|
||||
| Terminal panel | `Panels/TerminalPanel.swift` (SwiftTerm) | **In-container shell** (D2): hostd intent → broker `proc.exec(tty:true)` of `agentShellArgv` (nash) in the session container; bytes ride a per-terminal loopback side-channel vended by hostd; rendered in the Windows Terminal control; `proc.resize` on layout. No host ConPTY needed. |
|
||||
| Editor / diff | `Panels/EditorPanel.swift`, `SyntaxHighlighting.swift`, diff tab | Monaco in WebView2 (read-only diff-first); native later if desired. |
|
||||
| Build & Run | `BuildRunPanel.swift` | Host process spawn via hostd intent (ChildProcess + Job Objects). |
|
||||
| Settings | `SettingsView.swift` (2,995), `RemoteAccessView.swift`, `ModelCatalog.swift`, `QuotaIndicator.swift` | Sections that apply: agents/accounts (OAuth via loopback listener), sandbox (CPU/memory, restart-shared), remote access (QR pairing display, transports), updates (channel display; App Installer drives updates). |
|
||||
| Attention | `DockBounce.swift`, sound hooks (`AppStore.swift:668-681`) | `AppNotificationManager` toasts + `FlashWindowEx` + optional sound, wired to the same AppStore hook events over the wire. |
|
||||
| Accessibility/theming | 4 color-vision palettes + 5 text sizes (app-wide) | Resource dictionaries keyed off host-synced settings (`SyncedSettings.swift`); honor Windows high-contrast. |
|
||||
| Accessibility/theming | 4 color-vision palettes + 5 text sizes (app-wide) | Resource dictionaries keyed off host-synced settings (`SyncedSettings.swift`); honor Windows high-contrast. **Custom-drawn controls need more than this — see §7.1.** |
|
||||
|
||||
---
|
||||
|
||||
### 7.1 Custom-drawn controls need an explicit UI Automation contract
|
||||
|
||||
Most rows in the table above are ordinary controls, and WinUI hands those a UI Automation
|
||||
peer for free. The composer's **Intelligence rail**
|
||||
(`Sources/NucleicApp/IntelligenceSlider.swift`) is not one of those: it is a hand-drawn
|
||||
capsule, detent dots, and Orchestra constellation driven by a `DragGesture`. On macOS it
|
||||
therefore carries an *explicit* accessibility contract, and a naive port to a
|
||||
`Canvas`/`Path` composition would silently drop every part of it — the control would reach
|
||||
UIA as an unnamed image with no value, no keyboard path, and no way to select Orchestra.
|
||||
Port the contract, not just the pixels.
|
||||
|
||||
| Guarantee | macOS | WinUI 3 |
|
||||
|---|---|---|
|
||||
| Reads as a slider, not a drawing | `accessibilityRepresentation { Slider(…) }` | A `FrameworkElementAutomationPeer` subclass exposing `IRangeValueProvider`, with `GetAutomationControlTypeCore` → `Slider` |
|
||||
| **Orchestra is reachable without a pointer** | represented range is `1...6` when available; stop 6 is Orchestra | Same — `Maximum` is 6, not 5 (see below) |
|
||||
| Spoken value is the level name + resolved route, never a bare number | `.accessibilityValue("Balanced, routing to Sonnet 4.6, reasoning high")` | `IRangeValueProvider.Value` for the position **plus** the peer's `GetNameCore` for the words |
|
||||
| Purpose and cost are explained | `.accessibilityHint` | `AutomationProperties.HelpText` |
|
||||
| The disabled *reason* is spoken, not just dimmed | reason folded into the **value** — hints commonly go unread on disabled controls | Same: put it in the name/value, not only `HelpText`. `IsEnabled = false` on its own says nothing about why |
|
||||
| Async route resolution is announced | `AccessibilityNotification.Announcement`, gated to user-caused changes only | `AutomationPeer.RaiseNotificationEvent(…)`, same user-caused-only gate — the classifier re-runs as the user types, and narrating that would be intolerable |
|
||||
| Addressable by tests and inspectors | `.accessibilityIdentifier("intelligence-slider")` | `AutomationProperties.AutomationId` |
|
||||
| ←/→/↑/↓ step, Home/End jump to the ends | `onMoveCommand` + `onKeyPress(.home/.end)` | `OnKeyDown` — a custom peer's range provider does not supply key handling |
|
||||
| Keyboard focus is **visible** | custom ring, because the system ring is suppressed on this shape | Do not clear `UseSystemFocusVisuals` without drawing a replacement |
|
||||
| Reduce Motion stops the shimmer | `accessibilityReduceMotion` | `UISettings.AnimationsEnabled` |
|
||||
| High contrast raises the faint rail furniture | `accessibilityReduceTransparency` / `colorSchemeContrast` | `AccessibilitySettings.HighContrast` plus a `HighContrast` theme dictionary; `UISettings.AdvancedEffectsEnabled` for the transparency leg |
|
||||
| The route subtext scales with text size | `@ScaledMetric(relativeTo: .caption2)` | `UISettings.TextScaleFactor` — do not hard-code the row height |
|
||||
|
||||
Two of these are load-bearing enough to state plainly:
|
||||
|
||||
- **Orchestra must sit on the slider's automation range.** It is a distinct backend mode
|
||||
rather than a sixth capability level, and it is deliberately drawn *off* the rail across an
|
||||
elastic gap — but the rail is a single focusable control, and dragging that gap is the only
|
||||
other way to select it. An automation range that stops at Max makes Orchestra unreachable
|
||||
for every screen-reader and keyboard-only user. That is a functional gap, not a cosmetic one.
|
||||
- **The level is never conveyed by hue alone.** Fill length, detent dots, and the spoken
|
||||
value each carry it independently, because the app ships 4 color-vision palettes — the gold
|
||||
crest at Max is decoration, never the signal. Keep all three channels in the port.
|
||||
|
||||
Apply the same review to every other hand-drawn surface the renderer inherits —
|
||||
`QuotaIndicator.swift`, `StreakBadge`/the activity grid (`HomeView.swift`,
|
||||
`ActivityFeedView.swift`), and `OrchestraStyle.swift`'s glow. If a surface is drawn rather
|
||||
than composed from controls, it needs a peer, a name, a keyboard path, and a high-contrast
|
||||
story before it ships.
|
||||
|
||||
---
|
||||
|
||||
@@ -1294,7 +1339,11 @@ and safe to start immediately on macOS hardware.
|
||||
Trusted Signing.
|
||||
15. **naros multi-arch** — extend `os/` + `.github/workflows/naros.yml` to push an
|
||||
amd64+arm64 manifest list to GHCR (D10).
|
||||
16. **Phi Silica provider** (§9.2), accessibility/theming parity, polish.
|
||||
16. **Phi Silica provider** (§9.2), accessibility/theming parity, polish. Accessibility
|
||||
parity is not only the palettes and text sizes: every custom-drawn control needs its own
|
||||
UI Automation peer, keyboard path, focus visual, and high-contrast treatment (§7.1).
|
||||
Screens (item 12) should land these as they go rather than deferring them here — the
|
||||
Intelligence rail in the Composer is the worked example.
|
||||
|
||||
Packaging details (item 14): MSIX Identities `BLAKESLEE.Nucleic[.Dev/.Canary/.Beta/.RC]`
|
||||
mirroring the bundle-ID scheme (`Package.swift:27-35`); `runFullTrust`; payload = three
|
||||
|
||||
Reference in New Issue
Block a user