Skip to main content

max / alloy

Seed the per-user display config at install, and retire 10-fw12.conf The system drop-in shipped one laptop's scale to every machine: `output eDP-1 scale 1.25`, matched by a connector name every built-in panel uses. It could not be deleted while nothing seeded the per-user file, since deleting it booted the one validated machine at 1.0x. So the installer seeds it. There is no compositor to ask during an install, so the panel comes out of sysfs instead of swaymsg: /sys/class/drm names connectors with the same strings sway reports, the preferred mode is the first line of `modes`, and the physical size is in the EDID. Two places carry that size and they disagree. The basic display parameters are whole centimetres, so a 263mm panel reports 26 and the PPI lands 1.5% off; the first detailed timing descriptor carries millimetres outright. The descriptor wins, with the centimetres as fallback, and a descriptor whose pixel clock is zero is a monitor-name block whose bytes 12 to 14 are text rather than a size, which read as a panel 25mm wide. Density decides the rung. PPI_PER_SCALE is 185 / 1.25, which is the FW12 judgment restated as a ratio so a second panel can be answered without a second judgment, and the result snaps to the ladder the `s` key walks: seeding a scale the console cannot return to would make the first press jump somewhere nobody asked for. One data point makes this a rule, not a measurement, and every value it produces is one keypress from being overridden. detect_panel returns an Output, so the seed goes through display::config_file, the generator `alloy display` already uses. One generator, so a seeded file and a console-written one are the same file and the first press of `s` overwrites it with no special case. The plan writes it before the recursive chown for the same reason the ssh key does: a file written after it stays root-owned, which here would cost the user the one scale the console rewrites. A panel that cannot be read seeds nothing at all, which is 1.0 and one keypress from correct. etc/sway/config.d keeps a comment-only 00-alloy.conf. The include needs something to match, since sway expands the glob with wordexp and logs a failed open of a file literally named `*`, and git does not track empty directories.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-31 00:14 UTC
Signed with PGP, not checked
Commit: 0f8b04bfb6840a7a5f08ca83f4c4b6530a384678
Parent: 2bf4839
6 files changed, +544 insertions, -54 deletions
M docs/CONSOLE.md +1 -1
@@ -222,7 +222,7 @@
222 222
223 223 - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`, `alloy setup`, and all three `alloy pkg` tabs (`box`, plus `install` and `update` fronting `rpm-ostree status --json`). Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. `alloy pkg` forced three more pieces into it: `AlloyTabs`, a Cancel that views see before the shell claims it (a confirm needs a cancel that is not "exit the app"), and terminal suspend, so entering a box can hand the TTY to another interactive program.
224 224 - **In progress.** `alloy settings`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. The view architecture is settled (see "View architecture" above); build order is (1) schema parser and (2) the `Bind` seam with its `toml_edit` implementation, both pure and testable against `rio.toml.schema`, **both shipped**; (3) `AlloyForm` + `AlloyField` + `TextField` promotion as the `alloy_tui` 1.2 release, **shipped**; (4) tab chrome and the Applications tab, **shipped**; (5) per-field edit plus save and the quit-confirm, **shipped**, then the filterable pick overlay, **shipped**, which was the last field type that could not be changed; (6) the System tab, time rows first, **shipped** — one front, one enum, one bool, one display-only row, which is the smallest complete slice of the command side; (7) the remaining System rows, **shipped** except theme, which needs somewhere to persist a choice and a shell that can re-theme mid-run; (8) presets and live diagnostics, the collapsible sections having landed with the tab; (9) the text-edit fallback, save and dirty tracking having landed with the edit; (10) `AlloyTable` read-only.
225 - - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
225 + - **Written against a real capture, still short one case.** `alloy display` fronts `swaymsg` alone. `wlr-randr` is gone from this line and kanshi never arrived: neither is in the image, and sway re-applies stored `output` config on hotplug by connector name or by the `make model serial` identifier, which is the feature kanshi would have been carried for. The verb's one string serves both consumers, since `output eDP-1 scale 1.25` in a config file and `swaymsg output eDP-1 scale 1.25` at runtime are the same words after the command name; the console runs it and writes the identical text to `~/.config/sway/config.d/50-display.conf`, which the shipped sway config includes after `/etc/sway/config.d/*` so the user's file wins. The parser is written against a verbatim `swaymsg -t get_outputs` capture from the FW12 install (2026-07-29, sway 1.11) and re-checkable inside a session with `reads_this_machines_real_outputs`. The installer seeds that same file from the same generator, so a fresh machine boots at the scale its panel wants rather than at 1.0: there is no compositor to ask during an install, so the panel is read from sysfs and its physical size from EDID, and the scale is that density snapped to the ladder the `s` key walks. A panel that cannot be read seeds nothing, which is 1.0 and one keypress from correct. What is still missing is a multi-output capture: nobody has attached a second display to an Alloy machine, and that is the case parsers break on, so the mode picker is deliberately unbuilt (the one testable panel advertises exactly one mode).
226 226 - **Then.** `alloy theme`, which swaps the runtime theme in place (makeover consumer, no re-login). The first-boot flow (see [CONTINUITY.md](CONTINUITY.md)) shipped as `alloy setup`: two rows over the same `mesh` and `sync` backend seams, so there is no third enrollment path to keep in agreement with them. It survives the teardown mesh enrollment costs by refreshing on the first tick after a suspend rather than on its poll counter. The shell's post-suspend refresh arrives as one `tick` call, and a counter would swallow four out of five of them, leaving the row the user just enrolled reading "not enrolled" for several seconds. The screen appears once: the session autostart runs `alloy setup --if-first-boot`, and the console records that it asked in the same config store the theme lives in.
227 227 - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.)
228 228 - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned.
@@ -25,7 +25,7 @@
25 25 ## Display
26 26
27 27 - **Native panel:** 12.2" 1920x1200 (16:10), matte, IPS-class, ~185 PPI. Sits in the awkward middle where 1.0x is too small and 2.0x is too big.
28 - - **Alloy default scale:** 1.25x fractional under sway (`output eDP-1 scale 1.25`, shipped in `etc/sway/config.d/10-fw12.conf`). `alloy display` overrides it per user without touching that file: it writes the same directive to `~/.config/sway/config.d/50-display.conf`, which the sway config includes afterwards. sway handles the compositor side; terminal apps scale with the compositor, so there is no per-app fractional-scale work as there would have been for egui. Verify glyph crispness at v0 sanity check.
28 + - **Alloy default scale:** 1.25x fractional under sway (`output eDP-1 scale 1.25`). No longer shipped as a system file naming this laptop: the installer reads the panel's size out of its EDID and writes the directive to `~/.config/sway/config.d/50-display.conf`, from the same generator `alloy display` uses, so the value is the machine's own and its owner can change it with one keypress. This panel is the measurement the rule is anchored on — ~185 PPI takes 1.25, which is `PPI_PER_SCALE` in `crates/alloy/src/display.rs`. sway handles the compositor side; terminal apps scale with the compositor, so there is no per-app fractional-scale work as there would have been for egui. Verify glyph crispness at v0 sanity check.
29 29 - **Contrast/color:** validate the DESIGN-LANGUAGE.md dark-mode L stops on this panel specifically (currently deferred in `todo.md`). Matte 400-nit IPS is roughly the panel class most Alloy users are likely to have; if the tokens miss here, they miss for the base audience.
30 30
31 31 Rejected: 1.0x default (glyphs too small at arm's length on 12.2"), 1.5x default (wastes usable columns in a scrollable-tiling model where horizontal density matters).
@@ -58,6 +58,14 @@
58 58 //! sway config includes `~/.config/sway/config.d/*` after
59 59 //! `/etc/sway/config.d/*`, so the user's file wins on a conflicting property.
60 60 //!
61 + //! **The installer seeds that file, from the same generator.** A fresh machine
62 + //! has no console session behind it, so without a seed the first boot renders
63 + //! at 1.0 whatever the panel is. [`detect_panel`] reads the built-in panel out
64 + //! of sysfs — where the installer runs there is no compositor to ask — and
65 + //! `install.rs` writes [`config_file`] of it into the new home. One generator,
66 + //! so a seeded file and a console-written one are the same file, and the first
67 + //! press of `s` in `alloy display` overwrites it without a special case.
68 + //!
61 69 //! The path is built from `$HOME` and not from `$XDG_CONFIG_HOME`, which is the
62 70 //! one place this deliberately disagrees with the rest of the console. The
63 71 //! include line in the shipped config names `~/.config/sway/config.d/*`
@@ -102,8 +110,13 @@
102 110 use crate::cli::{CommandLog, Effect, Invocation};
103 111 use crate::shell::{Flow, View, block_title};
104 112
105 - /// The file this verb owns.
106 - const FILE: &str = ".config/sway/config.d/50-display.conf";
113 + /// The file this verb owns, relative to a home directory.
114 + ///
115 + /// Relative rather than absolute because the installer writes it too, into a
116 + /// home that is not `$HOME` — the target's, reached through a mountpoint. Both
117 + /// callers join it onto the home they mean, so there is one spelling of the
118 + /// path.
119 + pub(crate) const FILE: &str = ".config/sway/config.d/50-display.conf";
107 120
108 121 /// What sway reports for a field the panel does not carry.
109 122 ///
@@ -493,7 +506,8 @@
493 506 /// is not the same thing as a generated artifact.
494 507 pub(crate) fn config_file(outputs: &[Output]) -> String {
495 508 let mut out = String::from(
496 - "# Generated by `alloy display`. Regenerated whole on every change, so\n\
509 + "# Generated by Alloy: seeded at install from the panel this machine\n\
510 + # reports, and rewritten whole by `alloy display`, so\n\
497 511 # hand edits here are lost. Put your own output config in another file\n\
498 512 # in this directory: sway merges every stanza that matches an output,\n\
499 513 # and the shipped config includes all of ~/.config/sway/config.d/*.\n",
@@ -514,6 +528,148 @@
514 528 out
515 529 }
516 530
531 + // ---- the panel, before there is a sway to ask ----
532 +
533 + /// Where the kernel describes the connectors it found.
534 + ///
535 + /// One directory per connector, named `card<N>-<CONNECTOR>`, and the connector
536 + /// half is the same string sway reports as an output name. That correspondence
537 + /// is what lets the installer write a stanza the compositor will match later:
538 + /// both are reading DRM's vocabulary rather than inventing one.
539 + const DRM: &str = "/sys/class/drm";
540 +
541 + /// The PPI one step of scale is worth.
542 + ///
543 + /// 185 / 1.25, from the one panel anyone has looked at: the FW12's 12.2"
544 + /// 1920x1200 sits at ~185 PPI and takes 1.25, with 1.0 too small at arm's
545 + /// length and 1.5 wasting columns. docs/HARDWARE-FW12.md#display argues that
546 + /// choice; this constant is only that judgment restated as a ratio so a
547 + /// different panel can be answered without a second judgment.
548 + ///
549 + /// **One data point, so this is a rule and not a measurement.** It generalizes
550 + /// in the right direction — a denser panel gets more scale — and every value it
551 + /// produces is one keypress from being overridden, since `alloy display` writes
552 + /// the same file this seeds.
553 + const PPI_PER_SCALE: f64 = 148.0;
554 +
555 + /// The scale a panel of this geometry should come up at.
556 + ///
557 + /// Snapped to [`SCALES`] rather than used raw: the rungs are the values the
558 + /// console can walk, and seeding a scale the `s` key cannot return to would
559 + /// make the first press jump somewhere the user did not ask for. Off the ends
560 + /// of the ladder it clamps, which is what keeps a 1366x768 panel at 1.0 instead
561 + /// of below it.
562 + fn scale_for(pixels_wide: u32, millimetres_wide: u32) -> Option<f64> {
563 + if pixels_wide == 0 || millimetres_wide == 0 {
564 + return None;
565 + }
566 + let ppi = f64::from(pixels_wide) / (f64::from(millimetres_wide) / 25.4);
567 + let want = ppi / PPI_PER_SCALE;
568 + SCALES
569 + .iter()
570 + .copied()
571 + .min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs()))
572 + }
573 +
574 + /// The first mode a connector advertises, from its sysfs `modes` file.
575 + ///
576 + /// The first line is the preferred mode, which on a laptop panel is its native
577 + /// resolution and the only one it has. No refresh rate here: `modes` carries
578 + /// `1920x1200` and nothing else, which is the whole of what the scale needs.
579 + fn first_mode(modes: &str) -> Option<(u32, u32)> {
580 + let line = modes.lines().map(str::trim).find(|line| !line.is_empty())?;
581 + let (width, height) = line.split_once('x')?;
582 + Some((width.parse().ok()?, height.parse().ok()?))
583 + }
584 +
585 + /// The panel's physical size in millimetres, from its EDID.
586 + ///
587 + /// Two places carry it and they disagree in precision. The basic display
588 + /// parameters at 0x15 and 0x16 are whole centimetres, so a 263mm panel reports
589 + /// 26 and the PPI comes out 1.5% wrong; the first detailed timing descriptor
590 + /// carries millimetres outright, split across a shared byte of high nibbles.
591 + /// The descriptor is preferred and the centimetres are the fallback, which is
592 + /// the order every EDID reader uses.
593 + ///
594 + /// A descriptor whose pixel clock is zero is not a timing at all — that is how
595 + /// EDID marks the monitor-name and range-limit blocks — so its bytes 12 to 14
596 + /// mean something else entirely and reading them as a size gives a panel the
597 + /// dimensions of whatever text is stored there.
598 + fn panel_millimetres(edid: &[u8]) -> Option<(u32, u32)> {
599 + /// Start of the first detailed timing descriptor in the base block.
600 + const DTD: usize = 0x36;
601 +
602 + if edid.len() >= DTD + 15 && edid[DTD] | edid[DTD + 1] != 0 {
603 + let high = edid[DTD + 14];
604 + let width = u32::from(edid[DTD + 12]) | (u32::from(high >> 4) << 8);
605 + let height = u32::from(edid[DTD + 13]) | (u32::from(high & 0x0f) << 8);
606 + if width != 0 && height != 0 {
607 + return Some((width, height));
608 + }
609 + }
610 +
611 + let (width, height) = (
612 + u32::from(*edid.get(0x15)?) * 10,
613 + u32::from(*edid.get(0x16)?) * 10,
614 + );
615 + (width != 0 && height != 0).then_some((width, height))
616 + }
617 +
618 + /// Read one connector directory as an output, if it is a panel worth seeding.
619 + ///
620 + /// `None` for everything else: a disconnected connector, an external port, and
621 + /// a panel whose EDID does not say how big it is. Each of those is a machine
622 + /// this cannot answer for, and a guessed scale is worse than none — an install
623 + /// that seeds nothing comes up at 1.0, which is legible everywhere and one
624 + /// keypress from correct.
625 + fn panel_at(dir: &std::path::Path) -> Option<Output> {
626 + // `card1-eDP-1` is one card and one connector; sway names the second half.
627 + let name = dir.file_name()?.to_str()?.split_once('-')?.1.to_string();
628 + let panel = Output {
629 + name,
630 + make: String::new(),
631 + model: String::new(),
632 + serial: String::new(),
633 + active: true,
634 + dpms: true,
635 + focused: false,
636 + rect: Rectangle::default(),
637 + scale: 1.0,
638 + transform: "normal".into(),
639 + current_mode: None,
640 + modes: Vec::new(),
641 + };
642 + if !panel.built_in() {
643 + return None;
644 + }
645 + if std::fs::read_to_string(dir.join("status")).ok()?.trim() != "connected" {
646 + return None;
647 + }
648 +
649 + let (pixels_wide, _) = first_mode(&std::fs::read_to_string(dir.join("modes")).ok()?)?;
650 + let (millimetres_wide, _) = panel_millimetres(&std::fs::read(dir.join("edid")).ok()?)?;
651 + Some(Output {
652 + scale: scale_for(pixels_wide, millimetres_wide)?,
653 + ..panel
654 + })
655 + }
656 +
657 + /// The built-in panel of the machine this is running on, if it has one.
658 + ///
659 + /// For the installer, which runs on the target hardware and before any
660 + /// compositor: `swaymsg` has nobody to ask there, and the answer is in sysfs
661 + /// either way. Connectors are read in name order so a machine with two panels
662 + /// (none has been seen) seeds the lower-numbered one rather than whichever the
663 + /// directory listing happened to yield first.
664 + pub(crate) fn detect_panel() -> Option<Output> {
665 + let mut connectors: Vec<PathBuf> = std::fs::read_dir(DRM)
666 + .ok()?
667 + .filter_map(|entry| Some(entry.ok()?.path()))
668 + .collect();
669 + connectors.sort();
670 + connectors.iter().find_map(|dir| panel_at(dir))
671 + }
672 +
517 673 /// Where the file lives, or `None` on a machine with no `$HOME`.
518 674 fn config_path() -> Option<PathBuf> {
519 675 std::env::var_os("HOME").map(|home| PathBuf::from(home).join(FILE))
@@ -1446,6 +1602,182 @@
1446 1602 assert!(message.contains("eDP-1"), "{message}");
1447 1603 }
1448 1604
1605 + // ---- the installer's seed ----
1606 +
1607 + /// A 128-byte EDID base block carrying a size and nothing else that matters.
1608 + ///
1609 + /// Hand-built rather than captured: no EDID has been read off an Alloy
1610 + /// machine, and the two fields this parser reads are at fixed offsets that a
1611 + /// real dump would not make any more true. What a capture would add is
1612 + /// evidence that panels fill them the way the specification says, which is
1613 + /// the part still unverified.
1614 + ///
1615 + /// `descriptor` is the first detailed timing block's millimetres, or `None`
1616 + /// for a base block whose first descriptor is not a timing at all — the case
1617 + /// where the centimetres at 0x15 are all there is.
1618 + fn edid(centimetres: (u8, u8), descriptor: Option<(u32, u32)>) -> Vec<u8> {
1619 + const DTD: usize = 0x36;
1620 + let mut block = vec![0u8; 128];
1621 + block[..8].copy_from_slice(&[0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00]);
1622 + block[0x15] = centimetres.0;
1623 + block[0x16] = centimetres.1;
1624 + match descriptor {
1625 + Some((width, height)) => {
1626 + // A nonzero pixel clock is what marks the block as a timing.
1627 + block[DTD] = 0x01;
1628 + block[DTD + 12] = u8::try_from(width & 0xff).unwrap();
1629 + block[DTD + 13] = u8::try_from(height & 0xff).unwrap();
1630 + block[DTD + 14] =
1631 + u8::try_from(((width >> 8) << 4) | (height >> 8)).expect("a nibble each");
1632 + }
1633 + // Pixel clock zero: EDID's marker for a monitor-name or range-limit
1634 + // descriptor, whose bytes 12-14 are text rather than a size.
1635 + None => block[DTD + 12..DTD + 15].copy_from_slice(b"abc"),
1636 + }
1637 + block
1638 + }
1639 +
1640 + /// The FW12 panel: 12.2" 1920x1200, 263x164mm, ~185 PPI.
1641 + #[test]
1642 + fn the_one_validated_panel_seeds_the_scale_it_ships_with() {
1643 + assert_eq!(scale_for(1920, 263), Some(1.25));
1644 + }
1645 +
1646 + // The rule generalizes in the right direction, and clamps rather than
1647 + // running off either end of the ladder the `s` key walks.
1648 + #[test]
1649 + fn density_decides_the_rung() {
1650 + // 13.3" 2560x1600, ~227 PPI.
1651 + assert_eq!(scale_for(2560, 286), Some(1.5));
1652 + // 11.6" 1366x768, ~135 PPI: below the first rung, and stays on it.
1653 + assert_eq!(scale_for(1366, 256), Some(1.0));
1654 + // Denser than the ladder goes.
1655 + assert_eq!(scale_for(3840, 250), Some(2.0));
1656 + }
1657 +
1658 + // A missing dimension is a panel this cannot answer for, not a panel at 1.0.
1659 + #[test]
1660 + fn a_panel_with_no_geometry_gets_no_scale() {
1661 + assert_eq!(scale_for(0, 263), None);
1662 + assert_eq!(scale_for(1920, 0), None);
1663 + }
1664 +
1665 + // sysfs `modes` lists the preferred mode first and carries no refresh rate.
1666 + #[test]
1667 + fn the_preferred_mode_is_the_first_line() {
1668 + assert_eq!(first_mode("1920x1200\n1280x800\n"), Some((1920, 1200)));
1669 + assert_eq!(first_mode("\n\n1920x1200\n"), Some((1920, 1200)));
1670 + assert_eq!(first_mode(""), None);
1671 + assert_eq!(first_mode("nonsense\n"), None);
1672 + }
1673 +
1674 + // Millimetres beat centimetres: 26cm and 263mm are the same panel described
1675 + // to 1.5% different accuracy, and the PPI carries that error into the scale.
1676 + #[test]
1677 + fn the_timing_descriptor_wins_over_the_rounded_centimetres() {
1678 + let block = edid((26, 16), Some((263, 164)));
1679 + assert_eq!(panel_millimetres(&block), Some((263, 164)));
1680 + }
1681 +
1682 + // A descriptor with no pixel clock is a monitor name, and its bytes 12-14
1683 + // are letters. Reading those as a size gives a panel 25mm wide.
1684 + #[test]
1685 + fn a_descriptor_that_is_not_a_timing_falls_back_to_centimetres() {
1686 + let block = edid((26, 16), None);
1687 + assert_eq!(panel_millimetres(&block), Some((260, 160)));
1688 + }
1689 +
1690 + #[test]
1691 + fn an_edid_that_states_no_size_is_refused() {
1692 + assert_eq!(panel_millimetres(&edid((0, 0), None)), None);
1693 + assert_eq!(panel_millimetres(&[]), None);
1694 + // Truncated before the descriptor, and before 0x16.
1695 + assert_eq!(panel_millimetres(&[0u8; 0x16]), None);
1696 + }
1697 +
1698 + /// A connector directory as the kernel lays one out.
1699 + fn connector(name: &str, status: &str, modes: &str, edid: Option<Vec<u8>>) -> PathBuf {
1700 + let dir = std::env::temp_dir()
1701 + .join("alloy-display-connectors")
1702 + .join(name);
1703 + let _ = std::fs::remove_dir_all(&dir);
1704 + std::fs::create_dir_all(&dir).unwrap();
1705 + std::fs::write(dir.join("status"), format!("{status}\n")).unwrap();
1706 + std::fs::write(dir.join("modes"), modes).unwrap();
1707 + if let Some(edid) = edid {
1708 + std::fs::write(dir.join("edid"), edid).unwrap();
1709 + }
1710 + dir
1711 + }
1712 +
1713 + #[test]
1714 + fn a_connected_panel_reads_as_an_output_the_generator_accepts() {
1715 + let dir = connector(
1716 + "card1-eDP-1",
1717 + "connected",
1718 + "1920x1200\n",
1719 + Some(edid((26, 16), Some((263, 164)))),
1720 + );
1721 + let panel = panel_at(&dir).expect("a connected panel with a size");
1722 + assert_eq!(panel.name, "eDP-1");
1723 + assert_eq!(panel.identifier(), "eDP-1");
1724 + assert!((panel.scale - 1.25).abs() < f64::EPSILON);
1725 +
1726 + // The seed and a console write are the same file, from the same
1727 + // generator: this is the whole reason detection returns an `Output`.
1728 + let file = config_file(&[panel]);
1729 + assert!(file.contains("output eDP-1 scale 1.25"), "{file}");
1730 + assert!(!file.contains("transform"), "{file}");
1731 + assert!(!file.contains("enable"), "{file}");
1732 + }
1733 +
1734 + // Only the built-in panel. An external monitor plugged in during an install
1735 + // is not the machine's screen, and seeding its scale into the new home would
1736 + // configure hardware that is about to be unplugged.
1737 + #[test]
1738 + fn an_external_connector_is_not_a_panel() {
1739 + let dir = connector(
1740 + "card1-DP-1",
1741 + "connected",
1742 + "2560x1440\n",
1743 + Some(edid((60, 34), None)),
1744 + );
1745 + assert!(panel_at(&dir).is_none());
1746 + }
1747 +
1748 + #[test]
1749 + fn a_connector_with_nothing_on_it_is_skipped() {
1750 + let dir = connector("card1-eDP-2", "disconnected", "", None);
1751 + assert!(panel_at(&dir).is_none());
1752 + }
1753 +
1754 + // No EDID is the ordinary case on a connector the kernel has not read one
1755 + // from, and it means there is no size to compute a scale from.
1756 + #[test]
1757 + fn a_panel_with_no_edid_seeds_nothing() {
1758 + let dir = connector("card1-eDP-3", "connected", "1920x1200\n", None);
1759 + assert!(panel_at(&dir).is_none());
1760 + }
1761 +
1762 + /// Read this machine's real panel, the way an install would.
1763 + ///
1764 + /// Ignored by default because it asserts about hardware: it passes on a
1765 + /// laptop and says nothing on a desktop or in a container. Unlike the sway
1766 + /// test below it needs no session, so it runs anywhere with a panel.
1767 + ///
1768 + /// Run on fw13 (Framework 13, 2880x1920, EDID 285x190mm) on 2026-07-30 it
1769 + /// reads 257 PPI and seeds 1.75, which is the second panel the rule has been
1770 + /// put to and the first that is not the one it was anchored on. Neither is
1771 + /// an Alloy install, and the seed has still never been written by a real
1772 + /// installer run.
1773 + #[test]
1774 + #[ignore = "requires a machine with a built-in panel"]
1775 + fn reads_this_machines_real_panel() {
1776 + let panel = detect_panel().expect("a laptop has a built-in panel");
1777 + println!("{}", config_file(std::slice::from_ref(&panel)));
1778 + assert!(SCALES.contains(&panel.scale), "{}", panel.scale);
1779 + }
1780 +
1449 1781 /// Read this machine's real outputs.
1450 1782 ///
1451 1783 /// Ignored by default: needs a running sway, which no development box here
@@ -1120,6 +1120,22 @@
1120 1120 vec![enroll(passphrase), enroll(recovery)]
1121 1121 }
1122 1122
1123 + /// What lands in the new home besides the skeleton.
1124 + ///
1125 + /// Grouped because they are the same kind of thing and go to the same place:
1126 + /// both are optional, both are written under the home, and both have to precede
1127 + /// the recursive chown that makes it the user's. Neither is an answer the wizard
1128 + /// insists on — a key is optional by the minting decision, and a panel is
1129 + /// something the machine either reports or does not.
1130 + #[derive(Debug, Default)]
1131 + struct HomeSeeds<'a> {
1132 + /// The SSH public key to authorize, if one was given.
1133 + pubkey: Option<&'a str>,
1134 + /// The built-in panel, if this machine has one it can describe. See
1135 + /// [`crate::display::detect_panel`].
1136 + panel: Option<&'a crate::display::Output>,
1137 + }
1138 +
1123 1139 /// The commands that configure an already-deployed system.
1124 1140 ///
1125 1141 /// `root` is the ostree deployment directory, not the mountpoint. See
@@ -1133,7 +1149,7 @@
1133 1149 hostname: &str,
1134 1150 username: &str,
1135 1151 password: &str,
1136 - pubkey: Option<&str>,
1152 + seeds: &HomeSeeds<'_>,
1137 1153 locate_timezone: bool,
1138 1154 encryption: Option<&Encryption<'_>>,
1139 1155 root: &str,
@@ -1262,7 +1278,7 @@
1262 1278 // no second place that has to know the numeric ids. Writing the key after it
1263 1279 // would leave the directory root-owned, and sshd refuses an authorized_keys
1264 1280 // it does not trust the ownership of, silently.
1265 - if let Some(key) = pubkey {
1281 + if let Some(key) = seeds.pubkey {
1266 1282 let ssh_dir = format!("{home}/.ssh");
1267 1283 let authorized = format!("{ssh_dir}/authorized_keys");
1268 1284 stages.extend([
@@ -1296,6 +1312,34 @@
1296 1312 ]);
1297 1313 }
1298 1314
1315 + // The scale the panel wants, before anyone has logged in to set it.
1316 + //
1317 + // Same placement argument as the key above: the recursive chown below
1318 + // covers whatever is under the home, so writing here costs no second place
1319 + // that has to know the numeric ids.
1320 + //
1321 + // Passed in rather than detected here, because detection reads this
1322 + // machine's sysfs and a plan that changed shape with the panel of whoever
1323 + // ran the tests would be untestable. [`crate::display::detect_panel`] is
1324 + // called once, where the plan is built.
1325 + if let Some(panel) = seeds.panel {
1326 + let file = format!("{home}/{}", crate::display::FILE);
1327 + let dir = file
1328 + .rsplit_once('/')
1329 + .map_or_else(|| home.clone(), |(parent, _)| parent.to_string());
1330 + stages.extend([
1331 + Stage::Run(Invocation::new("mkdir").args(["-p", &dir])),
1332 + // `tee` and [`Secret`] for the reason the key above gives: an
1333 + // Invocation is argv with no shell, so stdin is the only way to
1334 + // hand over a whole file, and Secret is the only stdin it takes.
1335 + // Nothing here is secret, and the run screen showing the config it
1336 + // wrote is wanted.
1337 + Stage::Run(Invocation::new("tee").arg(&file).stdin(Secret::new(
1338 + crate::display::config_file(std::slice::from_ref(panel)),
1339 + ))),
1340 + ]);
1341 + }
1342 +
1299 1343 stages.extend([
1300 1344 // First discovery: which uid and gid useradd picked. Reading the file
1301 1345 // rather than asking getent, because getent answers about this machine.
@@ -1583,6 +1627,11 @@
1583 1627 let password = password.to_string();
1584 1628 let pubkey = pubkey.map(str::to_string);
1585 1629 let encrypt = encryption.is_some();
1630 + // Read here, once, on the machine being installed. The panel does not
1631 + // change while the install runs, and reading it before the disk is touched
1632 + // means a machine whose EDID cannot be read is one where nothing was seeded
1633 + // rather than one where a stage failed after the deploy.
1634 + let panel = crate::display::detect_panel();
1586 1635
1587 1636 vec![
1588 1637 // --wipe is explicit rather than implied by the confirm the user just
@@ -1712,7 +1761,10 @@
1712 1761 &hostname,
1713 1762 &username,
1714 1763 &password,
1715 - pubkey.as_deref(),
1764 + &HomeSeeds {
1765 + pubkey: pubkey.as_deref(),
1766 + panel: panel.as_ref(),
1767 + },
1716 1768 locate_timezone,
1717 1769 encryption.as_ref(),
1718 1770 deployment,
@@ -4384,7 +4436,7 @@
4384 4436 "workshop",
4385 4437 "max",
4386 4438 "hunter2",
4387 - None,
4439 + &HomeSeeds::default(),
4388 4440 false,
4389 4441 Some(&encryption),
4390 4442 DEPLOYMENT,
@@ -4498,11 +4550,19 @@
4498 4550 /// Without the location lookup, which is the default and what almost every
4499 4551 /// assertion below is about. `configured_locating` is the other one.
4500 4552 fn configured() -> Vec<String> {
4501 - configure_plan("workshop", "max", "hunter2", None, false, None, DEPLOYMENT)
4502 - .expect("a well-formed deployment path")
4503 - .iter()
4504 - .map(Stage::display)
4505 - .collect()
4553 + configure_plan(
4554 + "workshop",
4555 + "max",
4556 + "hunter2",
4557 + &HomeSeeds::default(),
4558 + false,
4559 + None,
4560 + DEPLOYMENT,
4561 + )
4562 + .expect("a well-formed deployment path")
4563 + .iter()
4564 + .map(Stage::display)
4565 + .collect()
4506 4566 }
4507 4567
4508 4568 /// Whether `line` runs `program`, wrapped in a chroot or not.
@@ -4954,12 +5014,19 @@
4954 5014
4955 5015 #[test]
4956 5016 fn a_located_install_asks_and_bounds_the_asking() {
4957 - let shown: Vec<String> =
4958 - configure_plan("workshop", "max", "hunter2", None, true, None, DEPLOYMENT)
4959 - .expect("a well-formed deployment path")
4960 - .iter()
4961 - .map(Stage::display)
4962 - .collect();
5017 + let shown: Vec<String> = configure_plan(
5018 + "workshop",
5019 + "max",
5020 + "hunter2",
5021 + &HomeSeeds::default(),
5022 + true,
5023 + None,
5024 + DEPLOYMENT,
5025 + )
5026 + .expect("a well-formed deployment path")
5027 + .iter()
5028 + .map(Stage::display)
5029 + .collect();
4963 5030
4964 5031 let lookup = shown
4965 5032 .iter()
@@ -5297,7 +5364,10 @@
5297 5364 "workshop",
5298 5365 "max",
5299 5366 "hunter2",
5300 - Some(key),
5367 + &HomeSeeds {
5368 + pubkey: Some(key),
5369 + ..HomeSeeds::default()
5370 + },
5301 5371 false,
5302 5372 None,
5303 5373 DEPLOYMENT,
@@ -5338,7 +5408,10 @@
5338 5408 "workshop",
5339 5409 "max",
5340 5410 "hunter2",
5341 - Some(key),
5411 + &HomeSeeds {
5412 + pubkey: Some(key),
5413 + ..HomeSeeds::default()
5414 + },
5342 5415 false,
5343 5416 None,
5344 5417 DEPLOYMENT,
@@ -5369,16 +5442,109 @@
5369 5442 // diagnose than an absent one.
5370 5443 #[test]
5371 5444 fn no_key_writes_no_ssh_directory() {
5372 - let joined = configure_plan("workshop", "max", "hunter2", None, false, None, DEPLOYMENT)
5373 - .unwrap()
5374 - .iter()
5375 - .map(Stage::display)
5376 - .collect::<Vec<_>>()
5377 - .join("\n");
5445 + let joined = configure_plan(
5446 + "workshop",
5447 + "max",
5448 + "hunter2",
5449 + &HomeSeeds::default(),
5450 + false,
5451 + None,
5452 + DEPLOYMENT,
5453 + )
5454 + .unwrap()
5455 + .iter()
5456 + .map(Stage::display)
5457 + .collect::<Vec<_>>()
5458 + .join("\n");
5378 5459 assert!(!joined.contains("authorized_keys"), "{joined}");
5379 5460 assert!(!joined.contains(".ssh"), "{joined}");
5380 5461 }
5381 5462
5463 + /// A panel as [`crate::display::detect_panel`] would return one.
5464 + fn panel() -> crate::display::Output {
5465 + crate::display::Output {
5466 + name: "eDP-1".into(),
5467 + make: String::new(),
5468 + model: String::new(),
5469 + serial: String::new(),
5470 + active: true,
5471 + dpms: true,
5472 + focused: false,
5473 + rect: crate::display::Rectangle::default(),
5474 + scale: 1.25,
5475 + transform: "normal".into(),
5476 + current_mode: None,
5477 + modes: Vec::new(),
5478 + }
5479 + }
5480 +
5481 + fn plan_with_panel(panel: Option<&crate::display::Output>) -> Vec<String> {
5482 + configure_plan(
5483 + "workshop",
5484 + "max",
5485 + "hunter2",
5486 + &HomeSeeds {
5487 + panel,
5488 + ..HomeSeeds::default()
5489 + },
5490 + false,
5491 + None,
5492 + DEPLOYMENT,
5493 + )
5494 + .expect("a well-formed deployment path")
5495 + .iter()
5496 + .map(Stage::display)
5497 + .collect()
5498 + }
5499 +
5500 + // The seed exists so a first boot renders at the scale the panel wants
5501 + // rather than at 1.0. Its directory has to be made first: the skel copy does
5502 + // not create one, since `alloy display` is what owns this path.
5503 + #[test]
5504 + fn a_detected_panel_seeds_the_per_user_display_config() {
5505 + let shown = plan_with_panel(Some(&panel()));
5506 + let joined = shown.join("\n");
5507 + assert!(joined.contains(crate::display::FILE), "{joined}");
5508 +
5509 + let made = shown
5510 + .iter()
5511 + .position(|line| line.contains("mkdir") && line.contains("sway/config.d"))
5512 + .expect("the plan makes the directory");
5513 + let wrote = shown
5514 + .iter()
5515 + .position(|line| line.contains(crate::display::FILE))
5516 + .expect("the plan writes the file");
5517 + assert!(made < wrote, "{shown:#?}");
5518 + }
5519 +
5520 + // Same argument as the ssh key: the recursive chown is what makes the home
5521 + // the user's, and a file written after it stays root-owned. Here that would
5522 + // cost the user a scale they cannot change from the console, since
5523 + // `alloy display` rewrites this exact file.
5524 + #[test]
5525 + fn the_seed_is_written_before_the_recursive_chown_that_owns_it() {
5526 + let shown = plan_with_panel(Some(&panel()));
5527 + let wrote = shown
5528 + .iter()
5529 + .position(|line| line.contains(crate::display::FILE))
5530 + .expect("the plan writes the file");
5531 + let discovers = shown
5532 + .iter()
5533 + .position(|line| line.contains("etc/passwd"))
5534 + .expect("the plan discovers the numeric ids");
5535 + assert!(wrote < discovers, "{shown:#?}");
5536 + }
5537 +
5538 + // A machine whose panel cannot be read seeds nothing at all. An empty or
5539 + // guessed file would be a scale nobody chose, in the one file the console
5540 + // regenerates whole.
5541 + #[test]
5542 + fn no_detected_panel_seeds_nothing() {
5543 + let joined = plan_with_panel(None).join("\n");
5544 + assert!(!joined.contains("50-display.conf"), "{joined}");
5545 + assert!(!joined.contains("sway"), "{joined}");
5546 + }
5547 +
5382 5548 // A field left as whitespace is a field nobody filled in, and it must not
5383 5549 // become a blank line in authorized_keys.
5384 5550 #[test]
@@ -1,0 +1,18 @@
1 + # System-wide sway drop-ins.
2 + #
3 + # The shipped config includes this directory and then
4 + # ~/.config/sway/config.d/*, so anything set here is the machine's default and
5 + # anything a user sets wins over it: sway merges every stanza that matches an
6 + # output, the later one taking a property both name.
7 + #
8 + # This file carries no directives. It is here so the include has something to
9 + # match — sway expands the glob with wordexp, which passes an unmatched pattern
10 + # through unchanged, leaving sway to log a failed open of a file literally named
11 + # `*` — and so the directory survives in git, which does not track empty ones.
12 + #
13 + # It used to carry 10-fw12.conf, `output eDP-1 scale 1.25`: the FW12's scale,
14 + # matched by a connector name every laptop panel uses, shipped to every machine
15 + # because nothing seeded the per-user file on a fresh install. The installer now
16 + # does, from the panel's own EDID (crates/alloy/src/display.rs, `detect_panel`),
17 + # so the machine's scale is written where its owner can change it rather than
18 + # into a root-owned file naming one laptop.
@@ -1,26 +1,0 @@
1 - # Framework Laptop 12 display tuning.
2 - #
3 - # 12.2" 1920x1200 (16:10), ~185 PPI. 1.0x puts glyphs too small at
4 - # arm's length and 1.5x wastes usable columns, so the panel sits in the
5 - # awkward middle that fractional scaling exists for. See
6 - # docs/HARDWARE-FW12.md#display for the full reasoning.
7 - #
8 - # SCOPE: this matches eDP-1, which is the internal panel on any laptop,
9 - # not just the FW12. Alloy has one hardware target today so shipping it
10 - # active is the pragmatic call.
11 - #
12 - # It is no longer the last word, which is what the old version of this
13 - # comment asked for. `alloy display` writes the same directive to
14 - # ~/.config/sway/config.d/50-display.conf, and the shipped sway config
15 - # includes that directory AFTER this one. sway merges every stanza that
16 - # matches an output, the later one winning on a property both name, so a
17 - # scale set in the console overrides this file without touching it. That
18 - # is the recovery path on hardware where 1.25x is wrong: open the console
19 - # and press `s`, rather than editing a root-owned file.
20 - #
21 - # Still shipped because nothing seeds the per-user file on a fresh install.
22 - # Deleting this today would boot the one validated machine at 1.0x. Full
23 - # retirement needs the installer to write a 50-display.conf from the panel
24 - # it detected, which is not built.
25 -
26 - output eDP-1 scale 1.25