diff --git a/apps/cadcraft/src/workspace.rs b/apps/cadcraft/src/workspace.rs
--- a/apps/cadcraft/src/workspace.rs
+++ b/apps/cadcraft/src/workspace.rs
@@ -47,12 +47,21 @@ fn snapshot(ui: &UiState) -> Result<Value, String> {
     if ui.history_lines > 12 {
         return Err("historyLines must be between 0 and 12".into());
     }
+    if ui.ribbon_tab.len() > 64 {
+        return Err("ribbonTab exceeds the workspace size limit".into());
+    }
+    ui.ribbon_visibility.validate()?;
     Ok(json!({
         "docking": ui.docking,
         "dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
         "showToolsets": ui.show_toolsets,
         "showPalettes": ui.show_palettes,
         "showToolbar": ui.show_toolbar,
+        "showRibbon": ui.show_ribbon,
+        "ribbonTab": ui.ribbon_tab,
+        "ribbonMinimized": ui.ribbon_minimized,
+        "ribbonVisibility": ui.ribbon_visibility,
+        "pcLayoutApplied": ui.pc_layout_applied,
         "showFileTabs": ui.show_file_tabs,
         "showStatusBar": ui.show_status_bar,
         "showCommandLine": ui.show_command_line,
@@ -84,17 +93,24 @@ fn read(path: &Path) -> Result<Option<UiState>, String> {
     }
     let workspace =
         value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
-    // Dock schemas can evolve independently of the other saved workspace preferences.
-    // Recover only this optional field; runtime UiState deserialization remains strict.
+    // Dock schemas and ribbon catalogs can evolve independently of other preferences.
+    // Recover these optional fields; runtime UiState deserialization remains strict.
     // A layout from before the current shape (split frames, saved without `dockingVersion`)
     // gives way to the default, its hidden panels with it.
     let mut fields = workspace.clone();
+    let visibility = fields.as_object_mut().and_then(|fields| fields.remove("ribbonVisibility"));
     let (docking, version) = match fields.as_object_mut() {
         Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")),
         None => (None, None),
     };
     let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION));
     let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
+    if let Some(visibility) = visibility
+        && let Ok(visibility) = serde_json::from_value::<cadcraft_ui_egui::ribbon::visibility::Visibility>(visibility)
+        && visibility.validate().is_ok()
+    {
+        ui.ribbon_visibility = visibility;
+    }
     if let Some(docking) = docking.filter(|_| current)
         && let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
         && cadcraft_ui_egui::docking::validate(&docking).is_ok()
@@ -255,6 +271,25 @@ mod tests {
         fs::remove_dir_all(directory).unwrap();
     }
 
+    #[test]
+    fn the_ribbon_survives_native_workspace_file_reload() {
+        let directory = temp_dir();
+        let path = directory.join("workspace.json");
+        let (mut first, _, _) = Workspace::at(Some(path.clone()));
+        let mut ui =
+            UiState { show_ribbon: true, ribbon_tab: "annotate".into(), ribbon_minimized: true, pc_layout_applied: true, ..Default::default() };
+        ui.ribbon_visibility.hidden_tabs.push("home".into());
+        ui.ribbon_visibility.hidden_panels.push(("home".into(), "Draw".into()));
+        first.save_if_changed(&ui, Instant::now()).unwrap();
+        let (_, restored, error) = Workspace::at(Some(path.clone()));
+        assert!(error.is_none(), "{error:?}");
+        let restored = restored.unwrap();
+        assert!(restored.show_ribbon && restored.ribbon_minimized && restored.pc_layout_applied);
+        assert_eq!(restored.ribbon_tab, "annotate");
+        assert_eq!(restored.ribbon_visibility, ui.ribbon_visibility);
+        fs::remove_dir_all(directory).unwrap();
+    }
+
     #[test]
     fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
         use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
@@ -359,6 +394,32 @@ mod tests {
         fs::remove_dir_all(directory).unwrap();
     }
 
+    #[test]
+    fn invalid_ribbon_visibility_preserves_workspace_and_future_saves() {
+        let directory = temp_dir();
+        let path = directory.join("workspace.json");
+        for visibility in [
+            json!({"hiddenTabs":["futureTab"]}),
+            json!({"hiddenPanels":[["home","Future Panel"]]}),
+            json!({"futureField":true}),
+            json!({"hiddenTabs":false}),
+        ] {
+            let workspace = json!({"showToolbar":false,"toolsetTab":"Modeling","historyLines":7,"ribbonVisibility":visibility});
+            fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
+            let (mut store, restored, error) = Workspace::at(Some(path.clone()));
+            assert!(error.is_none(), "optional visibility must not reject the workspace: {error:?}");
+            let mut restored = restored.unwrap();
+            assert!(!restored.show_toolbar);
+            assert_eq!(restored.toolset_tab, "Modeling");
+            assert_eq!(restored.history_lines, 7);
+            assert_eq!(restored.ribbon_visibility, Default::default());
+            restored.history_lines = 8;
+            store.save_on_exit(&restored).unwrap();
+            assert_eq!(read(&path).unwrap().unwrap().history_lines, 8);
+        }
+        fs::remove_dir_all(directory).unwrap();
+    }
+
     #[test]
     fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
         let directory = temp_dir();
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -61,13 +61,60 @@ fn title_left(left: f32, width: f32, text_w: f32) -> f32 {
     (left + (width - text_w) / 2.0).max(left + MAC_CONTENT_LEFT)
 }
 
+/// The Windows layout's quick access tool bar (title row, with the Ribbon).
+const QAT: &[(Icon, &str, &str, Option<&str>)] = &[
+    (Icon::New, "new", "New Drawing", Some("Cmd+N")),
+    (Icon::Open, "open", "Open", Some("Cmd+O")),
+    (Icon::Save, "qsave", "Save", Some("Cmd+S")),
+    (Icon::SaveAs, "saveas", "Save As", None),
+    (Icon::Plot, "plot", "Plot", Some("Cmd+P")),
+    (Icon::Undo, "u", "Undo", Some("Cmd+Z")),
+    (Icon::Redo, "redo", "Redo", Some("Cmd+Shift+Z")),
+];
+const QAT_SIZE: f32 = 22.0;
+
+/// Draw the quick access tool bar from `x` on the row centred at `cy`; returns its right edge.
+fn quick_access(ui: &mut egui::Ui, x: f32, cy: f32, clicked: &mut Option<&'static str>) -> f32 {
+    let t = Tokens::get();
+    let mac = ui.ctx().os().is_mac();
+    let mut x = x;
+    for (i, (icon, cmd, label, shortcut)) in QAT.iter().enumerate() {
+        if i == 5 {
+            x += 8.0;
+        }
+        let br = Rect::from_min_size(pos2(x, cy - QAT_SIZE / 2.0), vec2(QAT_SIZE, QAT_SIZE));
+        let resp = ui.interact(br, ui.scope_id().with(("qat", *cmd)), Sense::click());
+        if resp.hovered() {
+            ui.painter().rect_filled(br, 3.0, t.control_hover.gamma_multiply(0.6));
+        }
+        icons::paint(ui.painter(), br.shrink(3.0), *icon, false);
+        let tip = match shortcut {
+            Some(sc) => format!("{} ({})", crate::i18n::t(label), crate::menus::shortcut_label(sc, mac)),
+            None => crate::i18n::t(label).to_string(),
+        };
+        icons::describe_control(ui, &resp, &tip, None, false);
+        if resp.on_hover_text(tip).clicked() {
+            *clicked = Some(cmd);
+        }
+        x += QAT_SIZE + 4.0;
+    }
+    x
+}
+
 /// Title row (with the integrated macOS title bar) and the Tool Bar.
 pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
     let t = Tokens::get();
     let title_h = if app.integrated_titlebar { MAC_TITLE_BAR } else { 0.0 };
+    // With the Ribbon and no integrated title bar, the quick access tool bar gets its own row.
+    let qat_h = if app.ui.show_ribbon && !app.integrated_titlebar { 28.0 } else { 0.0 };
     let tb_h = if app.ui.show_toolbar { 34.0 } else { 0.0 };
-    egui::Panel::top("cc_title_toolbar").exact_size(title_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
+    egui::Panel::top("cc_title_toolbar").exact_size(title_h + qat_h + tb_h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
         let r = ui.max_rect();
+        let mut qat_clicked = None;
+        if qat_h > 0.0 {
+            quick_access(ui, r.left() + 10.0, r.top() + qat_h / 2.0, &mut qat_clicked);
+        }
+        let r = Rect::from_min_max(pos2(r.left(), r.top() + qat_h), r.max);
         if title_h > 0.0 {
             // The empty row moves the window; it goes first so later widgets take their own clicks.
             craft_window::title_bar::empty_area(ui, Rect::from_min_size(r.min, vec2(r.width(), title_h)));
@@ -78,8 +125,16 @@ pub fn title_and_toolbar(app: &mut CadApp, ui: &mut egui::Ui) {
             // Centred on the traffic lights' centre line (the row's middle): with the system font
             // the capitals' middle is the text box's middle, so no optical nudge.
             let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(13.0), t.text_dim);
-            let x = title_left(r.left(), r.width(), galley.size().x);
+            let qat_room = if app.ui.show_ribbon { QAT.len() as f32 * (QAT_SIZE + 4.0) + 24.0 } else { 0.0 };
+            let x = title_left(r.left(), r.width(), galley.size().x).max(r.left() + MAC_CONTENT_LEFT + qat_room);
             ui.painter().galley(pos2(x, r.top() + (title_h - galley.size().y) / 2.0), galley, t.text_dim);
+            // The Windows layout keeps its quick access tool bar in the title row.
+            if app.ui.show_ribbon {
+                quick_access(ui, r.left() + MAC_CONTENT_LEFT + 4.0, r.top() + title_h / 2.0, &mut qat_clicked);
+            }
+        }
+        if let Some(c) = qat_clicked {
+            app.start(c);
         }
         if tb_h > 0.0 {
             let row = Rect::from_min_size(pos2(r.left(), r.top() + title_h), vec2(r.width(), tb_h));
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -457,6 +457,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
                     if let Err(error) = crate::docking::validate(&u.docking) {
                         return err(error);
                     }
+                    if let Err(error) = u.ribbon_visibility.validate() {
+                        return err(error);
+                    }
                     app.ui = u;
                     ok(serde_json::to_value(&app.ui).unwrap_or_default())
                 }
diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs
--- a/crates/ui-egui/src/docking.rs
+++ b/crates/ui-egui/src/docking.rs
@@ -109,6 +109,21 @@ pub fn default_workspace() -> Workspace {
     })
 }
 
+/// The Windows layout: the Properties palette docked on the left of the drawing, no Tool Sets
+/// (the Ribbon holds the tools) and no Layers palette (the Ribbon's Layers panel holds the layer
+/// list).
+pub fn pc_workspace() -> Workspace {
+    Workspace::new(Layout {
+        root: Some(split(
+            SplitAxis::Horizontal,
+            SplitSize::FixedFirst(PALETTES_WIDTH),
+            dock(Side::Start, PALETTES_WIDTH, &[Panel::Properties]),
+            tabs(Panel::Canvas),
+        )),
+        floating: Vec::new(),
+    })
+}
+
 /// The dock a panel belongs in: Tool Sets on the left, the palettes on the right.
 fn home_side(panel: Panel) -> (Side, f32) {
     if panel == Panel::ToolSets { (Side::Start, TOOL_SETS_WIDTH) } else { (Side::End, PALETTES_WIDTH) }
@@ -288,7 +303,7 @@ fn sync(workspace: &mut Workspace, app: &crate::CadApp) -> Result<(), String> {
     Ok(())
 }
 
-fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {
+pub(crate) fn publish_visibility(app: &mut crate::CadApp, workspace: &Workspace) {
     app.ui.show_toolsets = workspace.layout.contains(&Panel::ToolSets);
     app.ui.show_palettes = workspace.layout.contains(&Panel::Layers) || workspace.layout.contains(&Panel::Properties);
 }
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -290,6 +290,12 @@ pub fn tr<'a>(language: &str, source: &'a str) -> &'a str {
     catalog(language).and_then(|c| c.get(&("", source))).copied().unwrap_or(source)
 }
 
+/// A Ribbon label: the catalog's `ribbon` row (the wording Windows CAD ribbons use in that
+/// language), else the ordinary translation.
+pub fn ribbon(source: &str) -> &str {
+    CURRENT.with(|l| catalog(l.get()).and_then(|c| c.get(&("ribbon", source))).copied().unwrap_or_else(|| tr(l.get(), source)))
+}
+
 pub fn t(source: &str) -> &str {
     CURRENT.with(|l| tr(l.get(), source))
 }
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -1015,3 +1015,177 @@
 	Check for Updates…	Rechercher des mises à jour…
 	CADCraft is up to date.	CADCraft est à jour.
 	Could not check for updates.	Impossible de rechercher les mises à jour.
+# Ribbon labels (context `ribbon`): the French wording drafting users know from Windows CAD ribbons (observed black-box, 2026-10-13).
+ribbon	Home	Début
+ribbon	Tab: Insert	Insertion
+ribbon	Insert	Insérer
+ribbon	Annotate	Annoter
+ribbon	Parametric	Paramétrique
+ribbon	View	Vue
+ribbon	Manage	Gérer
+ribbon	Output	Sortie
+ribbon	Collaborate	Collaborer
+ribbon	Draw	Dessin
+ribbon	Modify	Modification
+ribbon	Annotation	Annotation
+ribbon	Layers	Calques
+ribbon	Block	Bloc
+ribbon	Properties	Propriétés
+ribbon	Groups	Groupes
+ribbon	Utilities	Utilitaires
+ribbon	Clipboard	Presse-papiers
+ribbon	Block Definition	Définition de bloc
+ribbon	Reference	Référence
+ribbon	Import	Importer
+ribbon	Data	Données
+ribbon	Linking & Extraction	Liaison et extraction
+ribbon	Location	Position
+ribbon	Text	Texte
+ribbon	Dimensions	Cotes
+ribbon	Centerlines	Traits d'axe
+ribbon	Leaders	Lignes de repère
+ribbon	Tables	Tableaux
+ribbon	Markup	Annotation
+ribbon	Annotation Scaling	Echelle d'annotation
+ribbon	Geometric	Géométrique
+ribbon	Dimensional	Dimensionnelle
+ribbon	Viewport Tools	Outils de fenêtre
+ribbon	Named Views	Vues existantes
+ribbon	Model Viewports	Fenêtres d'espace objet
+ribbon	Review	Vérifier
+ribbon	History	Historique
+ribbon	Palettes	Palettes
+ribbon	Interface	Interface
+ribbon	Customization	Personnalisation
+ribbon	Applications	Applications
+ribbon	Cleanup	Nettoyer
+ribbon	Plot	Traceur
+ribbon	Export to DWF/PDF	Exporter au format DWF/PDF
+ribbon	Share	Partager
+ribbon	Compare	Comparer
+ribbon	Line	Ligne
+ribbon	Polyline	Polyligne
+ribbon	Circle	Cercle
+ribbon	Arc	Arc
+ribbon	Rectangle	Rectangle
+ribbon	Ellipse	Ellipse
+ribbon	Hatch	Hachures
+ribbon	Move	Déplacer
+ribbon	Copy	Copier
+ribbon	Stretch	Etirer
+ribbon	Rotate	Rotation
+ribbon	Mirror	Miroir
+ribbon	Scale	Echelle
+ribbon	Trim	Ajuster
+ribbon	Fillet	Raccord
+ribbon	Array	Réseau
+ribbon	Erase	Effacer
+ribbon	Explode	Décomposer
+ribbon	Offset	Décaler
+ribbon	Dimension	Cote
+ribbon	Linear	Linéaire
+ribbon	Leader	Ligne de repère
+ribbon	Table	Tableau
+ribbon	Layer Properties	Propriétés des calques
+ribbon	Make Current	Rendre courant
+ribbon	Match Layer	Faire correspondre le calque
+ribbon	Copy to Layer	Copier le calque
+ribbon	Create	Créer
+ribbon	Edit	Modifier
+ribbon	Edit Attributes	Modifier attributs
+ribbon	Match Properties	Copier les propriétés
+ribbon	Group	Grouper
+ribbon	Measure	Mesurer
+ribbon	Paste	Coller
+ribbon	Edit Attribute	Editer les attributs
+ribbon	Retain Attribute Display	Conserver l'affichage des attributs
+ribbon	Create Block	Créer un bloc
+ribbon	Define Attributes	Définir des attributs
+ribbon	Manage Attributes	Gérer les attributs
+ribbon	Block Editor	Editeur de blocs
+ribbon	Attach	Attacher
+ribbon	Clip	Délimiter
+ribbon	Adjust	Ajuster
+ribbon	External References	Références externes
+ribbon	Frames vary	Variation de cadres
+ribbon	Snap to Underlays	Accrochage aux fonds de plan
+ribbon	PDF Import	Importer en PDF
+ribbon	Recognize SHX Text	Reconnaître le texte SHX
+ribbon	Recognition Settings	Paramètres de reconnaissance
+ribbon	Combine Text	Combiner le texte
+ribbon	Field	Champ
+ribbon	Update Fields	Mettre à jour les champs
+ribbon	OLE Object	Objet OLE
+ribbon	Hyperlink	Hyperlien
+ribbon	Data Link	Liaison de données
+ribbon	Set Location	Définir l'emplacement
+ribbon	Multiline Text	Texte multiligne
+ribbon	Quick	Rapide
+ribbon	Continue	Continuer
+ribbon	Center Mark	Marque centrale
+ribbon	Centerline	Trait d'axe
+ribbon	Multileader	Ligne de repère multiple
+ribbon	Add Leader	Ajouter une ligne de repère
+ribbon	Remove Leader	Supprimer une ligne de repère
+ribbon	Link Data	Liaison de données
+ribbon	Wipeout	Masque
+ribbon	Revision Cloud	Nuage de révision
+ribbon	Add Current Scale	Ajouter échelle courante
+ribbon	Scale List	Liste d'échelles
+ribbon	Auto Constrain	Contrainte auto
+ribbon	Show/Hide	Afficher/Masquer
+ribbon	Show All	Tout afficher
+ribbon	Hide All	Tout masquer
+ribbon	Delete Constraints	Supprimer les contraintes
+ribbon	Parameters Manager	Gestionnaire des paramètres
+ribbon	UCS Icon	Icône du SCU
+ribbon	Navigation Cube	Cube de navigation
+ribbon	Navigation Bar	Barre de navigation
+ribbon	New View	Nouvelle vue
+ribbon	View Manager	Gestionnaire des vues
+ribbon	Viewport Configuration	Configuration de la fenêtre
+ribbon	Named	Nommé
+ribbon	Join	Joindre
+ribbon	Restore	Restaurer
+ribbon	DWG Compare	Comparaison de fichiers DWG
+ribbon	Activity Insights	Informations sur l'activité
+ribbon	Tool Palettes	Palettes d'outils
+ribbon	Blocks	Blocs
+ribbon	Count	Quantité
+ribbon	Sheet Set Manager	Gestionnaire du jeu de feuilles
+ribbon	Switch Windows	Basculer les fenêtres
+ribbon	File Tabs	Onglets Fichier
+ribbon	Layout Tabs	Onglets de présentation
+ribbon	Tile Horizontally	Mosaïque horizontale
+ribbon	Tile Vertically	Mosaïque verticale
+ribbon	Cascade	Cascade
+ribbon	User Interface	Interface utilisateur
+ribbon	Export	Exporter
+ribbon	Edit Aliases	Modifier les alias
+ribbon	Load Application	Charger une application
+ribbon	Run Script	Exécuter le script
+ribbon	Find Non-Purgeable Items	Rechercher des éléments ne pouvant pas être purgés
+ribbon	Purge	Purger
+ribbon	Overkill	Epurer
+ribbon	Audit	Contrôler
+ribbon	Batch Plot	Tracé par lot
+ribbon	Preview	Aperçu
+ribbon	Page Setup Manager	Gestionnaire des mises en page
+ribbon	View Details	Afficher les détails
+ribbon	Plotter Manager	Gestionnaire de traçage
+ribbon	Share Drawing	Partager le dessin
+ribbon	Shared Views	Vues partagées
+ribbon	Off	Désactiver
+ribbon	Isolate	Isoler
+ribbon	Freeze	Geler
+ribbon	Lock	Verrouiller
+ribbon	Unlock	Déverrouiller
+ribbon	Previous	Précédent
+ribbon	Turn All Layers On	Activer tous les calques
+ribbon	Unisolate	Annuler l'isolement
+ribbon	Thaw All Layers	Libérer tous les calques
+ribbon	Quick Select	Sélection rapide
+ribbon	Command Line	Ligne de commande
+	Ribbon	Ruban
+	Windows Layout (Ribbon)	Disposition Windows (ruban)
+	Mac Layout (Tool Sets)	Disposition Mac (palettes)
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -20,7 +20,7 @@ fn catalog_is_valid_and_preserves_placeholders_and_ellipses() {
         let (catalog, errors) = parse(language.source);
         assert!(errors.is_empty(), "{}: {errors:?}", language.code);
         for ((context, key), value) in catalog {
-            assert!(["", "menu", "@plural"].contains(&context), "unknown context: {context}");
+            assert!(["", "menu", "ribbon", "@plural"].contains(&context), "unknown context: {context}");
             let forms: Vec<_> = if context == "@plural" { value.split('|').collect() } else { vec![value] };
             if context == "@plural" {
                 assert_eq!(forms.len(), language.plural_forms, "{}: {key}", language.code);
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -1371,3 +1371,11 @@
 	Check for Updates…	Перевірити оновлення…
 	CADCraft is up to date.	Встановлено найновішу версію CADCraft.
 	Could not check for updates.	Не вдалося перевірити оновлення.
+	Ribbon	Стрічка
+	Windows Layout (Ribbon)	Розкладка Windows (стрічка)
+	Mac Layout (Tool Sets)	Розкладка Mac (набори інструментів)
+	Ribbon Item Visibility	Видимість елементів стрічки
+	Show All Ribbon Items	Показати всі елементи стрічки
+	Show Tabs	Показати вкладки
+	Show Panels	Показати панелі
+	Ribbon Customization	Налаштування стрічки
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -46,6 +46,7 @@ pub mod palettes;
 pub mod parametric;
 pub mod plotstyles;
 pub mod quick;
+pub mod ribbon;
 pub mod shortcut_menus;
 pub mod theme;
 #[cfg(not(target_arch = "wasm32"))]
@@ -97,6 +98,17 @@ pub struct UiState {
     /// menu puts them back.
     pub hidden_tools: Vec<String>,
     pub properties_all: bool,
+    /// The Ribbon above the drawing (the Windows layout).
+    pub show_ribbon: bool,
+    /// The Ribbon tab shown (`ribbon::TABS` ids).
+    pub ribbon_tab: String,
+    /// Ribbon reduced to its tab row (double-click a tab).
+    pub ribbon_minimized: bool,
+    /// User-selected tab and panel visibility, shared by native and browser hosts.
+    pub ribbon_visibility: ribbon::visibility::Visibility,
+    /// The desktop app has set up the Windows layout once (Ribbon, Properties docked
+    /// left, no Tool Sets); later choices of layout are the user's (`ui.layout.pc` / `.mac`).
+    pub pc_layout_applied: bool,
     /// Show an in-window menu bar (platforms without a native menu bar, or on request).
     pub in_window_menu: bool,
     pub start_tab: bool,
@@ -178,6 +190,11 @@ impl Default for UiState {
             collapsed_groups: Vec::new(),
             hidden_tools: Vec::new(),
             properties_all: true,
+            show_ribbon: false,
+            ribbon_tab: "home".into(),
+            ribbon_minimized: false,
+            ribbon_visibility: ribbon::visibility::Visibility::default(),
+            pc_layout_applied: false,
             in_window_menu: !cfg!(target_os = "macos"),
             start_tab: false,
             dialog: None,
@@ -434,6 +451,9 @@ impl CadApp {
         if !self.ui.recent_files.is_empty() {
             prefs["recentFiles"] = json!(self.ui.recent_files);
         }
+        if self.ui.ribbon_visibility != ribbon::visibility::Visibility::default() {
+            prefs["ribbonVisibility"] = json!(self.ui.ribbon_visibility);
+        }
         if let Some(dir) = &self.ui.last_lisp_dir {
             prefs["lispDir"] = json!(dir);
         }
@@ -443,6 +463,12 @@ impl CadApp {
     /// Restore preferences saved by [`Self::prefs_json`]; unknown or malformed values are ignored.
     pub fn load_prefs(&mut self, json: &str) {
         let Ok(v) = serde_json::from_str::<Value>(json) else { return };
+        if let Some(saved) = v.get("ribbonVisibility")
+            && let Ok(visibility) = serde_json::from_value::<ribbon::visibility::Visibility>(saved.clone())
+            && visibility.validate().is_ok()
+        {
+            self.ui.ribbon_visibility = visibility;
+        }
         if let Some(prefs) = v.get("spacemouse") {
             let _ = self.spacemouse.load_settings(prefs);
         }
@@ -817,9 +843,13 @@ impl CadApp {
         control::begin_frame(ui.ctx());
         let t = theme::Tokens::get();
         chrome::title_and_toolbar(self, ui);
+        // Top panels stack in call order: the menu bar sits above the Ribbon, as on Windows.
         if self.ui.in_window_menu {
             menus::menu_bar(self, ui);
         }
+        if self.ui.show_ribbon {
+            ribbon::ribbon(self, ui);
+        }
         if self.ui.show_status_bar {
             chrome::status_bar(self, ui);
         }
@@ -827,7 +857,8 @@ impl CadApp {
             chrome::file_tabs(self, ui);
         }
         let has_doc = !self.session.docs.is_empty() && !self.ui.start_tab;
-        if has_doc && !self.ui.show_toolsets {
+        // With the Ribbon the tools are in it: no collapsed Tool Sets strip.
+        if has_doc && !self.ui.show_toolsets && !self.ui.show_ribbon {
             palettes::toolsets_strip(self, ui);
         }
         egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -35,6 +35,11 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[
     ("ui.animations.off", "Off", &["View", "Accessibility", "Animation Effects", "Off"], None),
     ("ui.animations", "Animation Effects", &[], None),
     ("ui.toggle.toolbar", "Tool Bar", &["Window", "Tool Bar"], None),
+    ("ui.toggle.ribbon", "Ribbon", &["Window", "Ribbon"], None),
+    ("ui.ribbon.visibility", "Ribbon Item Visibility", &[], None),
+    ("ui.ribbon.visibility.reset", "Show All Ribbon Items", &["Window", "Show All Ribbon Items"], None),
+    ("ui.layout.pc", "Windows Layout (Ribbon)", &["Window", "Windows Layout (Ribbon)"], None),
+    ("ui.layout.mac", "Mac Layout (Tool Sets)", &["Window", "Mac Layout (Tool Sets)"], None),
     ("ui.toggle.filetabs", "File Tab", &["Window", "File Tab"], None),
     ("ui.toggle.statusbar", "Status Bar", &["Window", "Status Bar"], None),
     ("ui.toggle.cmdline", "Command Line", &["Window", "Command Line"], Some("Cmd+9")),
@@ -118,6 +123,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
     if let Some(r) = crate::options::route(app, id, params) {
         return Some(r);
     }
+    if let Some(r) = crate::ribbon::visibility::route(app, id, params) {
+        return Some(r);
+    }
     let toggle = |b: &mut bool, p: &Value| {
         *b = p.get("on").and_then(Value::as_bool).unwrap_or(!*b);
     };
@@ -243,6 +251,18 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
             toggle(&mut app.ui.show_toolbar, params);
             Ok(Value::Null)
         }
+        "ui.toggle.ribbon" => {
+            toggle(&mut app.ui.show_ribbon, params);
+            Ok(Value::Null)
+        }
+        "ui.layout.pc" => {
+            crate::ribbon::apply_pc_layout(app);
+            Ok(Value::Null)
+        }
+        "ui.layout.mac" => {
+            crate::ribbon::apply_mac_layout(app);
+            Ok(Value::Null)
+        }
         "ui.toggle.filetabs" => {
             toggle(&mut app.ui.show_file_tabs, params);
             Ok(Value::Null)
@@ -339,7 +359,17 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
         "ui.resetpalettes" => {
             let (menu, theme, interface_language, save_format) = (app.ui.in_window_menu, app.ui.theme, app.ui.interface_language, app.ui.save_format);
             let (animations, save_version) = (app.ui.animations, std::mem::take(&mut app.ui.save_version));
-            app.ui = crate::UiState { in_window_menu: menu, theme, animations, interface_language, save_format, save_version, ..Default::default() };
+            let ribbon_visibility = std::mem::take(&mut app.ui.ribbon_visibility);
+            app.ui = crate::UiState {
+                in_window_menu: menu,
+                theme,
+                animations,
+                interface_language,
+                save_format,
+                save_version,
+                ribbon_visibility,
+                ..Default::default()
+            };
             Ok(Value::Null)
         }
         "ui.start" => {
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/ribbon.rs
@@ -0,0 +1,1274 @@
+//! The Ribbon: a Windows-style tabbed command bar, with tabs, panels and buttons where 2D
+//! drafting users on Windows expect them (observed black-box). Every button runs a command (`app.start`); a command
+//! CADCraft doesn't have yet shows greyed out in its place, so positions stay where users expect
+//! them. Icons are CADCraft's own (`icons.rs`); a command without one shows its initial.
+//!
+//! Panels that don't fit the window collapse from the right into a single button that opens the
+//! panel's commands as a menu, as Windows CAD ribbons do.
+
+use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
+use serde_json::json;
+
+use crate::CadApp;
+use crate::icons::{self, Icon};
+use crate::theme::Tokens;
+
+pub mod visibility;
+
+/// One ribbon command: id (as `app.start` takes it), label (English source, translated by the
+/// interface catalog) and icon.
+#[derive(Clone, Copy)]
+pub struct Cmd {
+    pub id: &'static str,
+    pub label: &'static str,
+    pub icon: Option<Icon>,
+}
+
+const fn c(id: &'static str, label: &'static str, icon: Icon) -> Cmd {
+    Cmd { id, label, icon: Some(icon) }
+}
+const fn n(id: &'static str, label: &'static str) -> Cmd {
+    Cmd { id, label, icon: None }
+}
+
+/// A current-value list in a panel (layer, colour, styles…).
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum Widget {
+    Layer,
+    Color,
+    Lineweight,
+    Linetype,
+    TextStyle,
+    DimStyle,
+    MleaderStyle,
+    TableStyle,
+    NamedView,
+}
+
+/// A column of a panel.
+pub enum Cell {
+    /// Large button (icon above, label below); `variants` open from its lower half (▾).
+    Big(Cmd, &'static [Cmd]),
+    /// Up to three small buttons (icon, label to the right), each with optional variants.
+    Small(&'static [(Cmd, &'static [Cmd])]),
+    /// Icon-only buttons, three per column.
+    Icons(&'static [Cmd]),
+    /// Current-value lists stacked in one column of the given width.
+    Lists(&'static [Widget], f32),
+    /// A list on the first row and icon buttons on the two rows under it (the Layers panel).
+    ListIcons(Widget, f32, &'static [Cmd]),
+}
+
+pub struct PanelDef {
+    pub title: &'static str,
+    pub cells: &'static [Cell],
+    /// Commands under the panel title's ▾ (the expanded panel).
+    pub more: &'static [Cmd],
+}
+
+pub struct TabDef {
+    pub id: &'static str,
+    pub title: &'static str,
+    pub panels: &'static [PanelDef],
+}
+
+// ------------------------------------------------------------------------------------------
+// Tabs (the Windows 2D drafting layout; 3D tabs are left out on purpose)
+// ------------------------------------------------------------------------------------------
+
+const CIRCLES: &[Cmd] = &[
+    c("circle", "Center, Radius", Icon::Circle),
+    n("circle.cd", "Center, Diameter"),
+    n("circle.2p", "2-Point"),
+    n("circle.3p", "3-Point"),
+    n("circle.ttr", "Tan, Tan, Radius"),
+    n("circle.ttt", "Tan, Tan, Tan"),
+];
+const ARCS: &[Cmd] = &[
+    c("arc", "3-Point", Icon::Arc),
+    n("arc.sce", "Start, Center, End"),
+    n("arc.sca", "Start, Center, Angle"),
+    n("arc.scl", "Start, Center, Length"),
+    n("arc.sea", "Start, End, Angle"),
+    n("arc.sed", "Start, End, Direction"),
+    n("arc.ser", "Start, End, Radius"),
+    n("arc.cse", "Center, Start, End"),
+    n("arc.csa", "Center, Start, Angle"),
+    n("arc.csl", "Center, Start, Length"),
+    n("arc.continue", "Continue"),
+];
+const RECTS: &[Cmd] = &[c("rectang", "Rectangle", Icon::Rectangle), c("polygon", "Polygon", Icon::Polygon)];
+const ELLIPSES: &[Cmd] = &[c("ellipse", "Center", Icon::Ellipse), n("ellipse.axis", "Axis, End"), n("ellipse.arc", "Elliptical Arc")];
+const HATCHES: &[Cmd] = &[c("hatch", "Hatch", Icon::Hatch), c("gradient", "Gradient", Icon::Gradient), c("boundary", "Boundary", Icon::Boundary)];
+const TRIMS: &[Cmd] = &[c("trim", "Trim", Icon::Trim), c("extend", "Extend", Icon::Extend)];
+const FILLETS: &[Cmd] = &[c("fillet", "Fillet", Icon::Fillet), c("chamfer", "Chamfer", Icon::Chamfer), n("blend", "Blend Curves")];
+const ARRAYS: &[Cmd] =
+    &[c("arrayrect", "Rectangular Array", Icon::ArrayRect), n("arraypath", "Path Array"), c("arraypolar", "Polar Array", Icon::ArrayPolar)];
+const TEXTS: &[Cmd] = &[c("mtext", "Multiline Text", Icon::MText), c("text", "Single Line", Icon::Text)];
+const DIMS: &[Cmd] = &[
+    c("dimlinear", "Linear", Icon::DimLinear),
+    c("dimaligned", "Aligned", Icon::DimAligned),
+    c("dimangular", "Angular", Icon::DimAngular),
+    c("dimarc", "Arc Length", Icon::DimArc),
+    c("dimradius", "Radius", Icon::DimRadius),
+    c("dimdiameter", "Diameter", Icon::DimDiameter),
+    n("dimjogged", "Jogged"),
+    c("dimordinate", "Ordinate", Icon::DimOrdinate),
+];
+const LEADERS: &[Cmd] = &[
+    c("mleader", "Multileader", Icon::MLeader),
+    c("mleaderedit.add", "Add Leader", Icon::AddLeader),
+    c("mleaderedit.remove", "Remove Leader", Icon::RemoveLeader),
+];
+const INSERTS: &[Cmd] = &[c("insert", "Insert", Icon::Insert), c("ui.dialog.blocks", "Blocks Palette", Icon::Blocks)];
+const ATT_EDITS: &[Cmd] = &[n("eattedit", "Single"), n("-attedit", "Multiple"), n("attsync", "Synchronize")];
+const MEASURES: &[Cmd] = &[
+    c("measuregeom", "Distance", Icon::Distance),
+    n("measuregeom.radius", "Radius"),
+    n("measuregeom.angle", "Angle"),
+    c("area", "Area", Icon::Area),
+];
+const PASTES: &[Cmd] =
+    &[n("pasteclip", "Paste"), n("pasteblock", "Paste as Block"), n("pasteorig", "Paste to Original Coordinates"), n("pastespec", "Paste Special")];
+const ATT_DISPLAY: &[Cmd] =
+    &[n("attdisp.normal", "Retain Attribute Display"), n("attdisp.on", "Display All Attributes"), n("attdisp.off", "Hide All Attributes")];
+const BLOCK_CREATE: &[Cmd] = &[c("block", "Create Block", Icon::BlockCreate), n("wblock", "Write Block")];
+const PDF_IMPORTS: &[Cmd] = &[n("pdfimport", "PDF Import"), n("import", "Import")];
+const REVCLOUDS: &[Cmd] = &[
+    c("revcloud", "Rectangular", Icon::RevCloud),
+    n("revcloud.polygonal", "Polygonal"),
+    n("revcloud.freehand", "Freehand"),
+    n("revcloud.object", "Object"),
+];
+const VPORT_CONFIGS: &[Cmd] =
+    &[n("vports.1", "Single"), n("vports.2", "Two"), n("vports.3", "Three"), n("vports.4", "Four"), n("vports", "Named...")];
+const EXPORTS: &[Cmd] = &[n("exportpdf", "PDF"), n("exportdwf", "DWF"), n("exportdwfx", "DWFx"), n("export", "Other Formats")];
+const CONTINUES: &[Cmd] = &[c("dimcontinue", "Continue", Icon::DimContinue), c("dimbaseline", "Baseline", Icon::DimBaseline)];
+
+const NONE: &[Cmd] = &[];
+
+pub const TABS: &[TabDef] = &[
+    TabDef {
+        id: "home",
+        title: "Home",
+        panels: &[
+            PanelDef {
+                title: "Draw",
+                cells: &[
+                    Cell::Big(c("line", "Line", Icon::Line), NONE),
+                    Cell::Big(c("pline", "Polyline", Icon::Polyline), NONE),
+                    Cell::Big(c("circle", "Circle", Icon::Circle), CIRCLES),
+                    Cell::Big(c("arc", "Arc", Icon::Arc), ARCS),
+                    Cell::Small(&[
+                        (c("rectang", "Rectangle", Icon::Rectangle), RECTS),
+                        (c("ellipse", "Ellipse", Icon::Ellipse), ELLIPSES),
+                        (c("hatch", "Hatch", Icon::Hatch), HATCHES),
+                    ]),
+                ],
+                more: &[
+                    c("spline", "Spline Fit", Icon::Spline),
+                    c("xline", "Construction Line", Icon::XLine),
+                    c("ray", "Ray", Icon::Ray),
+                    c("point", "Multiple Points", Icon::Point),
+                    n("divide", "Divide"),
+                    n("measure", "Measure"),
+                    c("polygon", "Polygon", Icon::Polygon),
+                    c("donut", "Donut", Icon::Donut),
+                    c("region", "Region", Icon::Region),
+                    c("wipeout", "Wipeout", Icon::Wipeout),
+                    c("revcloud", "Revision Cloud", Icon::RevCloud),
+                    n("3dpoly", "3D Polyline"),
+                ],
+            },
+            PanelDef {
+                title: "Modify",
+                cells: &[
+                    Cell::Small(&[
+                        (c("move", "Move", Icon::Move), NONE),
+                        (c("copy", "Copy", Icon::Copy), NONE),
+                        (c("stretch", "Stretch", Icon::Stretch), NONE),
+                    ]),
+                    Cell::Small(&[
+                        (c("rotate", "Rotate", Icon::Rotate), NONE),
+                        (c("mirror", "Mirror", Icon::Mirror), NONE),
+                        (c("scale", "Scale", Icon::Scale), NONE),
+                    ]),
+                    Cell::Small(&[
+                        (c("trim", "Trim", Icon::Trim), TRIMS),
+                        (c("fillet", "Fillet", Icon::Fillet), FILLETS),
+                        (c("arrayrect", "Array", Icon::ArrayRect), ARRAYS),
+                    ]),
+                    Cell::Icons(&[c("erase", "Erase", Icon::Erase), c("explode", "Explode", Icon::Explode), c("offset", "Offset", Icon::Offset)]),
+                ],
+                more: &[
+                    c("break", "Break", Icon::Break),
+                    n("breakatpoint", "Break at Point"),
+                    c("join", "Join", Icon::Join),
+                    c("lengthen", "Lengthen", Icon::Lengthen),
+                    c("chamfer", "Chamfer", Icon::Chamfer),
+                    n("align", "Align"),
+                    n("reverse", "Reverse"),
+                    n("pedit", "Edit Polyline"),
+                    n("splinedit", "Edit Spline"),
+                    n("hatchedit", "Edit Hatch"),
+                    n("setbylayer", "Set to ByLayer"),
+                    n("overkill", "Delete Duplicate Objects"),
+                    n("draworder", "Draw Order"),
+                    n("flatten", "Flatten"),
+                ],
+            },
+            PanelDef {
+                title: "Annotation",
+                cells: &[
+                    Cell::Big(c("mtext", "Text", Icon::MText), TEXTS),
+                    Cell::Big(c("dim", "Dimension", Icon::DimLinear), NONE),
+                    Cell::Small(&[
+                        (c("dimlinear", "Linear", Icon::DimLinear), DIMS),
+                        (c("mleader", "Leader", Icon::MLeader), LEADERS),
+                        (c("table", "Table", Icon::Table), NONE),
+                    ]),
+                ],
+                more: &[
+                    n("style", "Text Style"),
+                    n("dimstyle", "Dimension Style"),
+                    n("mleaderstyle", "Multileader Style"),
+                    n("tablestyle", "Table Style"),
+                ],
+            },
+            PanelDef {
+                title: "Layers",
+                cells: &[
+                    Cell::Big(c("ui.dialog.layers", "Layer Properties", Icon::LayerProps), NONE),
+                    Cell::ListIcons(
+                        Widget::Layer,
+                        168.0,
+                        &[
+                            c("layoff", "Off", Icon::LayerOff),
+                            c("layiso", "Isolate", Icon::LayerIso),
+                            c("layfrz", "Freeze", Icon::LayerFreeze),
+                            c("laylck", "Lock", Icon::LayerLock),
+                            n("layon", "Turn All Layers On"),
+                            n("layuniso", "Unisolate"),
+                            n("laythw", "Thaw All Layers"),
+                            c("layulk", "Unlock", Icon::Unlock),
+                            c("layerp", "Previous", Icon::LayerPrev),
+                        ],
+                    ),
+                    Cell::Small(&[
+                        (c("laymcur", "Make Current", Icon::MakeCurrent), NONE),
+                        (c("laymch", "Match Layer", Icon::LayerMatch), NONE),
+                        (n("copytolayer", "Copy to Layer"), NONE),
+                    ]),
+                ],
+                more: &[
+                    n("layon", "Turn All Layers On"),
+                    n("laythw", "Thaw All Layers"),
+                    n("layuniso", "Unisolate"),
+                    n("laywalk", "Layer Walk"),
+                    n("laymrg", "Merge"),
+                    n("laydel", "Delete"),
+                    n("layvpi", "Isolate to Current Viewport"),
+                    n("layerstate", "Layer States"),
+                ],
+            },
+            PanelDef {
+                title: "Block",
+                cells: &[
+                    Cell::Big(c("insert", "Insert", Icon::Insert), INSERTS),
+                    Cell::Small(&[
+                        (c("block", "Create", Icon::BlockCreate), NONE),
+                        (c("bedit", "Edit", Icon::BlockEdit), NONE),
+                        (n("eattedit", "Edit Attributes"), ATT_EDITS),
+                    ]),
+                ],
+                more: &[c("attdef", "Define Attributes", Icon::AttDef), n("battman", "Manage Attributes"), n("attsync", "Synchronize Attributes")],
+            },
+            PanelDef {
+                title: "Properties",
+                cells: &[
+                    Cell::Big(c("matchprop", "Match Properties", Icon::MatchProp), NONE),
+                    Cell::Lists(&[Widget::Color, Widget::Lineweight, Widget::Linetype], 140.0),
+                ],
+                more: &[n("transparency", "Transparency"), c("list", "List", Icon::List), n("setbylayer", "Set to ByLayer")],
+            },
+            PanelDef {
+                title: "Groups",
+                cells: &[
+                    Cell::Big(n("group", "Group"), NONE),
+                    Cell::Icons(&[n("ungroup", "Ungroup"), n("groupedit", "Group Edit"), n("pickstyle", "Group Selection On/Off")]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Utilities",
+                cells: &[
+                    Cell::Big(c("measuregeom", "Measure", Icon::Measure), MEASURES),
+                    Cell::Icons(&[
+                        c("ui.dialog.qselect", "Quick Select", Icon::QuickSelect),
+                        n("ui.panel.quickcalc", "QuickCalc"),
+                        n("id", "ID Point"),
+                    ]),
+                ],
+                more: &[n("selectall", "Select All"), n("count", "Count")],
+            },
+            PanelDef {
+                title: "Clipboard",
+                cells: &[
+                    Cell::Big(n("pasteclip", "Paste"), PASTES),
+                    Cell::Icons(&[n("copyclip", "Copy Clip"), n("cutclip", "Cut"), n("copybase", "Copy with Base Point")]),
+                ],
+                more: NONE,
+            },
+        ],
+    },
+    TabDef {
+        id: "insert",
+        title: "Insert",
+        panels: &[
+            PanelDef {
+                title: "Block",
+                cells: &[
+                    Cell::Big(c("insert", "Insert", Icon::Insert), INSERTS),
+                    Cell::Small(&[(n("eattedit", "Edit Attribute"), ATT_EDITS), (n("attdisp", "Retain Attribute Display"), ATT_DISPLAY)]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Block Definition",
+                cells: &[
+                    Cell::Big(c("block", "Create Block", Icon::BlockCreate), BLOCK_CREATE),
+                    Cell::Big(c("attdef", "Define Attributes", Icon::AttDef), NONE),
+                    Cell::Big(n("battman", "Manage Attributes"), NONE),
+                    Cell::Big(c("bedit", "Block Editor", Icon::BlockEdit), NONE),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Reference",
+                cells: &[
+                    Cell::Big(c("attach", "Attach", Icon::Attach), NONE),
+                    Cell::Big(n("clip", "Clip"), NONE),
+                    Cell::Big(n("adjust", "Adjust"), NONE),
+                    Cell::Small(&[
+                        (n("xref", "External References"), NONE),
+                        (n("frame", "Frames vary"), NONE),
+                        (n("uosnap", "Snap to Underlays"), NONE),
+                    ]),
+                ],
+                more: &[n("xclip", "Clip Xref"), n("xbind", "Bind"), n("imageattach", "Attach Image"), n("pdfattach", "Attach PDF")],
+            },
+            PanelDef {
+                title: "Import",
+                cells: &[
+                    Cell::Big(n("pdfimport", "PDF Import"), PDF_IMPORTS),
+                    Cell::Small(&[(n("pdfshxtext", "Recognize SHX Text"), NONE), (n("pdfshxtextoptions", "Recognition Settings"), NONE)]),
+                    Cell::Big(n("txt2mtxt", "Combine Text"), NONE),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Data",
+                cells: &[
+                    Cell::Big(n("field", "Field"), NONE),
+                    Cell::Small(&[
+                        (n("updatefield", "Update Fields"), NONE),
+                        (n("insertobj", "OLE Object"), NONE),
+                        (n("hyperlink", "Hyperlink"), NONE),
+                    ]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Linking & Extraction",
+                cells: &[
+                    Cell::Big(n("datalink", "Data Link"), NONE),
+                    Cell::Icons(&[n("dataextraction", "Extract Data"), n("datalinkupdate", "Update Data Links")]),
+                ],
+                more: NONE,
+            },
+            PanelDef { title: "Location", cells: &[Cell::Big(n("geographiclocation", "Set Location"), NONE)], more: NONE },
+        ],
+    },
+    TabDef {
+        id: "annotate",
+        title: "Annotate",
+        panels: &[
+            PanelDef {
+                title: "Text",
+                cells: &[
+                    Cell::Big(c("mtext", "Multiline Text", Icon::MText), TEXTS),
+                    Cell::Lists(&[Widget::TextStyle], 170.0),
+                    Cell::Icons(&[n("spell", "Check Spelling"), c("find", "Find Text", Icon::Search), n("scaletext", "Scale Text")]),
+                ],
+                more: &[n("justifytext", "Justify"), n("textalign", "Text Align"), n("style", "Text Style")],
+            },
+            PanelDef {
+                title: "Dimensions",
+                cells: &[
+                    Cell::Big(c("dim", "Dimension", Icon::DimLinear), NONE),
+                    Cell::Lists(&[Widget::DimStyle], 170.0),
+                    Cell::Small(&[
+                        (c("dimlinear", "Linear", Icon::DimLinear), DIMS),
+                        (c("qdim", "Quick", Icon::QDim), NONE),
+                        (c("dimcontinue", "Continue", Icon::DimContinue), CONTINUES),
+                    ]),
+                    Cell::Icons(&[
+                        n("dimbreak", "Break"),
+                        n("dimspace", "Adjust Space"),
+                        n("diminspect", "Inspect"),
+                        n("dimjogline", "Jog Line"),
+                        n("dimreassociate", "Reassociate"),
+                        n("dimtedit", "Text Angle"),
+                    ]),
+                ],
+                more: &[n("dimstyle", "Dimension Style"), n("dimoverride", "Override"), n("dimedit.oblique", "Oblique"), n("tolerance", "Tolerance")],
+            },
+            PanelDef {
+                title: "Centerlines",
+                cells: &[Cell::Big(n("centermark", "Center Mark"), NONE), Cell::Big(n("centerline", "Centerline"), NONE)],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Leaders",
+                cells: &[
+                    Cell::Big(c("mleader", "Multileader", Icon::MLeader), NONE),
+                    Cell::Lists(&[Widget::MleaderStyle], 160.0),
+                    Cell::Small(&[
+                        (c("mleaderedit.add", "Add Leader", Icon::AddLeader), NONE),
+                        (c("mleaderedit.remove", "Remove Leader", Icon::RemoveLeader), NONE),
+                    ]),
+                    Cell::Icons(&[c("mleaderalign", "Align", Icon::AlignLeaders), n("mleadercollect", "Collect")]),
+                ],
+                more: &[n("mleaderstyle", "Multileader Style")],
+            },
+            PanelDef {
+                title: "Tables",
+                cells: &[
+                    Cell::Big(c("table", "Table", Icon::Table), NONE),
+                    Cell::Lists(&[Widget::TableStyle], 150.0),
+                    Cell::Small(&[(n("datalink", "Link Data"), NONE), (n("dataextraction", "Extract Data"), NONE)]),
+                ],
+                more: &[n("tablestyle", "Table Style"), n("tableexport", "Export")],
+            },
+            PanelDef {
+                title: "Markup",
+                cells: &[
+                    Cell::Big(c("wipeout", "Wipeout", Icon::Wipeout), NONE),
+                    Cell::Big(c("revcloud", "Revision Cloud", Icon::RevCloud), REVCLOUDS),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Annotation Scaling",
+                cells: &[
+                    Cell::Big(c("objectscale", "Add Current Scale", Icon::Annotation), NONE),
+                    Cell::Small(&[(n("scalelistedit", "Scale List"), NONE), (n("annoupdate", "Sync Scale Positions"), NONE)]),
+                ],
+                more: NONE,
+            },
+        ],
+    },
+    TabDef {
+        id: "parametric",
+        title: "Parametric",
+        panels: &[
+            PanelDef {
+                title: "Geometric",
+                cells: &[
+                    Cell::Big(c("autoconstrain", "Auto Constrain", Icon::Constraint), NONE),
+                    Cell::Icons(&[
+                        c("gccoincident", "Coincident", Icon::Coincident),
+                        c("gcparallel", "Parallel", Icon::Parallel),
+                        c("gcperpendicular", "Perpendicular", Icon::Perpendicular),
+                    ]),
+                    Cell::Icons(&[
+                        c("gchorizontal", "Horizontal", Icon::Horizontal),
+                        c("gcvertical", "Vertical", Icon::Vertical),
+                        c("gctangent", "Tangent", Icon::Tangent),
+                    ]),
+                    Cell::Icons(&[
+                        c("gcconcentric", "Concentric", Icon::Concentric),
+                        c("gcequal", "Equal", Icon::Equal),
+                        c("gcfix", "Fix", Icon::Fix),
+                    ]),
+                    Cell::Small(&[
+                        (n("constraintbar", "Show/Hide"), NONE),
+                        (n("constraintbar.showall", "Show All"), NONE),
+                        (n("constraintbar.hideall", "Hide All"), NONE),
+                    ]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Dimensional",
+                cells: &[
+                    Cell::Small(&[(n("dclinear", "Linear"), NONE), (n("dcaligned", "Aligned"), NONE), (n("dcangular", "Angular"), NONE)]),
+                    Cell::Small(&[(n("dcradius", "Radius"), NONE), (n("dcdiameter", "Diameter"), NONE), (n("dcconvert", "Convert"), NONE)]),
+                    Cell::Small(&[
+                        (n("dcdisplay", "Show/Hide"), NONE),
+                        (n("dcdisplay.showall", "Show All"), NONE),
+                        (n("dcdisplay.hideall", "Hide All"), NONE),
+                    ]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Manage",
+                cells: &[Cell::Big(n("delconstraint", "Delete Constraints"), NONE), Cell::Big(n("ui.dialog.parameters", "Parameters Manager"), NONE)],
+                more: NONE,
+            },
+        ],
+    },
+    TabDef {
+        id: "view",
+        title: "View",
+        panels: &[
+            PanelDef {
+                title: "Viewport Tools",
+                cells: &[
+                    Cell::Big(n("ui.toggle.ucsicon", "UCS Icon"), NONE),
+                    Cell::Big(n("ui.toggle.viewcube", "Navigation Cube"), NONE),
+                    Cell::Big(n("navbar", "Navigation Bar"), NONE),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Named Views",
+                cells: &[Cell::Lists(&[Widget::NamedView], 170.0), Cell::Small(&[(n("view", "New View"), NONE), (n("view", "View Manager"), NONE)])],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Model Viewports",
+                cells: &[
+                    Cell::Big(n("vports", "Viewport Configuration"), VPORT_CONFIGS),
+                    Cell::Small(&[(n("vports", "Named"), NONE), (n("-vports.join", "Join"), NONE), (n("vports.1", "Restore"), NONE)]),
+                ],
+                more: NONE,
+            },
+            PanelDef { title: "Review", cells: &[Cell::Big(n("compare", "DWG Compare"), NONE)], more: NONE },
+            PanelDef { title: "History", cells: &[Cell::Big(n("activityinsights", "Activity Insights"), NONE)], more: NONE },
+            PanelDef {
+                title: "Palettes",
+                cells: &[
+                    Cell::Big(n("toolpalettes", "Tool Palettes"), NONE),
+                    Cell::Big(c("ui.panel.properties", "Properties", Icon::Properties), NONE),
+                    Cell::Big(c("ui.dialog.blocks", "Blocks", Icon::Blocks), NONE),
+                    Cell::Big(n("count", "Count"), NONE),
+                    Cell::Big(n("sheetset", "Sheet Set Manager"), NONE),
+                    Cell::Icons(&[
+                        n("ui.toggle.cmdline", "Command Line"),
+                        c("ui.panel.layers", "Layers", Icon::Layers),
+                        n("ui.panel.quickcalc", "QuickCalc"),
+                    ]),
+                    Cell::Icons(&[n("xref", "External References"), n("ui.toggle.history", "Text Window"), n("markupimport", "Markup Import")]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Interface",
+                cells: &[
+                    Cell::Big(c("ui.switcher", "Switch Windows", Icon::Switcher), NONE),
+                    Cell::Big(n("ui.toggle.filetabs", "File Tabs"), NONE),
+                    Cell::Big(n("ui.toggle.layouttabs", "Layout Tabs"), NONE),
+                    Cell::Small(&[
+                        (n("ui.tile.horizontal", "Tile Horizontally"), NONE),
+                        (n("ui.tile.vertical", "Tile Vertically"), NONE),
+                        (n("ui.cascade", "Cascade"), NONE),
+                    ]),
+                ],
+                more: NONE,
+            },
+        ],
+    },
+    TabDef {
+        id: "manage",
+        title: "Manage",
+        panels: &[
+            PanelDef {
+                title: "Customization",
+                cells: &[
+                    Cell::Big(n("cui", "User Interface"), NONE),
+                    Cell::Big(n("toolpalettes", "Tool Palettes"), NONE),
+                    Cell::Small(&[(n("cuiimport", "Import"), NONE), (n("cuiexport", "Export"), NONE), (n("aliasedit", "Edit Aliases"), NONE)]),
+                ],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Applications",
+                cells: &[Cell::Big(n("appload", "Load Application"), NONE), Cell::Big(n("script", "Run Script"), NONE)],
+                more: NONE,
+            },
+            PanelDef {
+                title: "Cleanup",
+                cells: &[
+                    Cell::Big(n("purge.find", "Find Non-Purgeable Items"), NONE),
+                    Cell::Small(&[(n("purge", "Purge"), NONE), (n("overkill", "Overkill"), NONE), (n("audit", "Audit"), NONE)]),
+                ],
+                more: NONE,
+            },
+        ],
+    },
+    TabDef {
+        id: "output",
+        title: "Output",
+        panels: &[
+            PanelDef {
+                title: "Plot",
+                cells: &[
+                    Cell::Big(c("plot", "Plot", Icon::Plot), NONE),
+                    Cell::Big(c("publish", "Batch Plot", Icon::Publish), NONE),
+                    Cell::Big(c("preview", "Preview", Icon::Preview), NONE),
+                    Cell::Small(&[
+                        (c("pagesetup", "Page Setup Manager", Icon::PageSetup), NONE),
+                        (n("viewplotdetails", "View Details"), NONE),
+                        (n("plottermanager", "Plotter Manager"), NONE),
+                    ]),
+                ],
+                more: NONE,
+            },
+            PanelDef { title: "Export to DWF/PDF", cells: &[Cell::Big(c("exportpdf", "Export", Icon::Export), EXPORTS)], more: NONE },
+        ],
+    },
+    TabDef {
+        id: "collaborate",
+        title: "Collaborate",
+        panels: &[
+            PanelDef {
+                title: "Share",
+                cells: &[Cell::Big(c("share", "Share Drawing", Icon::Share), NONE), Cell::Big(n("sharedviews", "Shared Views"), NONE)],
+                more: NONE,
+            },
+            PanelDef { title: "Compare", cells: &[Cell::Big(n("compare", "DWG Compare"), NONE)], more: NONE },
+        ],
+    },
+];
+
+// ------------------------------------------------------------------------------------------
+// Layout
+// ------------------------------------------------------------------------------------------
+
+/// Height of the tab row.
+pub const TAB_ROW: f32 = 24.0;
+/// Height of the panel body (three small-button rows) and of the panel title row under it.
+const BODY: f32 = 72.0;
+const TITLE: f32 = 18.0;
+/// Whole ribbon height when expanded.
+pub const HEIGHT: f32 = TAB_ROW + BODY + TITLE + 4.0;
+
+const ROW: f32 = BODY / 3.0;
+const BIG_ICON: f32 = 30.0;
+const SMALL_ICON: f32 = 16.0;
+const PAD: f32 = 3.0;
+
+/// Ribbon text is a little smaller than the palettes' body text.
+fn font() -> FontId {
+    FontId::proportional(11.0)
+}
+
+/// A tab's title: the catalog's `Tab: <title>` row when the tab is named differently from a
+/// button with the same English word (Insert tab, Insert button), else the plain label.
+fn tab_label(title: &str) -> String {
+    let key = format!("Tab: {title}");
+    let tab = crate::i18n::ribbon(&key);
+    if tab == key { crate::i18n::ribbon(title).to_string() } else { tab.to_string() }
+}
+
+/// Whether the command exists (else its button is greyed out).
+pub fn known(id: &str) -> bool {
+    cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id)
+}
+
+fn text_w(ui: &egui::Ui, s: &str) -> f32 {
+    ui.painter().layout_no_wrap(crate::i18n::ribbon(s).to_string(), font(), Color32::WHITE).size().x
+}
+
+/// A big button's label in up to two lines (split at the space nearest the middle).
+fn two_lines(label: &str) -> (String, Option<String>) {
+    let s = crate::i18n::ribbon(label);
+    if s.chars().count() <= 10 || !s.contains(' ') {
+        return (s.to_string(), None);
+    }
+    let mid = s.len() / 2;
+    let split = s.char_indices().filter(|(_, ch)| *ch == ' ').map(|(i, _)| i).min_by_key(|i| i.abs_diff(mid)).unwrap_or(mid);
+    let (a, b) = s.split_at(split);
+    (a.to_string(), Some(b.trim_start().to_string()))
+}
+
+fn cell_width(ui: &egui::Ui, cell: &Cell) -> f32 {
+    match cell {
+        Cell::Big(cmd, variants) => {
+            let (a, b) = two_lines(cmd.label);
+            let w = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font(), Color32::WHITE).size().x;
+            let lw = w(&a).max(b.as_deref().map_or(0.0, w)) + if variants.is_empty() { 0.0 } else { 10.0 };
+            lw.max(BIG_ICON + 4.0) + 2.0 * PAD
+        }
+        Cell::Small(rows) => {
+            rows.iter().map(|(cmd, v)| SMALL_ICON + 5.0 + text_w(ui, cmd.label) + if v.is_empty() { 2.0 } else { 13.0 }).fold(0.0, f32::max)
+                + 2.0 * PAD
+        }
+        Cell::Icons(cmds) => (cmds.len().div_ceil(3) as f32) * (SMALL_ICON + 8.0) + PAD,
+        Cell::Lists(_, w) | Cell::ListIcons(_, w, _) => *w + 2.0 * PAD,
+    }
+}
+
+fn panel_width(ui: &egui::Ui, panel: &PanelDef) -> f32 {
+    let cells: f32 = panel.cells.iter().map(|c| cell_width(ui, c)).sum();
+    cells.max(text_w(ui, panel.title) + if panel.more.is_empty() { 12.0 } else { 24.0 }) + PAD
+}
+
+/// Width of a collapsed panel (a single button with the panel's title).
+fn collapsed_width(ui: &egui::Ui, panel: &PanelDef) -> f32 {
+    text_w(ui, panel.title).max(BIG_ICON) + 4.0 * PAD + 10.0
+}
+
+/// Which panels are shown in full: all of them while they fit, then the rightmost ones collapse
+/// first, as Windows CAD ribbons do when the window narrows.
+pub fn fit(full: &[f32], collapsed: &[f32], room: f32) -> Vec<bool> {
+    let mut expanded = vec![true; full.len()];
+    let total = |e: &[bool]| -> f32 {
+        e.iter().enumerate().map(|(i, x)| if *x { full.get(i).copied().unwrap_or(0.0) } else { collapsed.get(i).copied().unwrap_or(0.0) }).sum()
+    };
+    let mut i = full.len();
+    while total(&expanded) > room && i > 0 {
+        i -= 1;
+        if let Some(e) = expanded.get_mut(i) {
+            *e = false;
+        }
+    }
+    expanded
+}
+
+// ------------------------------------------------------------------------------------------
+// Drawing
+// ------------------------------------------------------------------------------------------
+
+/// What a click in the ribbon asks for.
+enum Action {
+    Start(&'static str),
+    Tab(&'static str),
+    ToggleMinimized,
+    Layer(String),
+    LayerToggle(String, &'static str, bool),
+}
+
+fn hover_fill(ui: &egui::Ui, r: Rect, resp: &egui::Response) {
+    if resp.hovered() {
+        ui.painter().rect_filled(r, 3.0, Tokens::get().control_hover.gamma_multiply(0.6));
+    }
+}
+
+fn paint_icon(p: &egui::Painter, r: Rect, cmd: &Cmd, dim: bool) {
+    match cmd.icon {
+        Some(icon) => icons::paint(p, r, icon, dim),
+        None => {
+            let t = Tokens::get();
+            let col = if dim { t.text_faint } else { t.icon };
+            p.rect_stroke(r.shrink(r.width() * 0.08), 2.0, Stroke::new(1.0, col), egui::StrokeKind::Inside);
+            let initial: String = crate::i18n::ribbon(cmd.label).chars().next().map(|ch| ch.to_uppercase().collect()).unwrap_or_default();
+            p.text(r.center(), Align2::CENTER_CENTER, initial, FontId::proportional(r.height() * 0.55), col);
+        }
+    }
+}
+
+fn tooltip(cmd: &Cmd, enabled: bool) -> String {
+    let label = crate::i18n::ribbon(cmd.label);
+    if enabled { format!("{label} ({})", cmd.id.to_uppercase()) } else { format!("{label} — {}", crate::tl!("not available yet")) }
+}
+
+/// A menu of command variants (or a collapsed panel's commands).
+fn variant_menu(ui: &mut egui::Ui, items: &[Cmd], out: &mut Option<Action>) {
+    for v in items {
+        let enabled = known(v.id);
+        let resp = ui.add_enabled(enabled, egui::Button::new(crate::i18n::ribbon(v.label)));
+        crate::control::record_widget(ui, &resp, v.label, "button", None);
+        if resp.clicked() {
+            *out = Some(Action::Start(v.id));
+            ui.close();
+        }
+    }
+}
+
+fn big(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, variants: &'static [Cmd], out: &mut Option<Action>) {
+    let t = Tokens::get();
+    let enabled = known(cmd.id);
+    let top = Rect::from_min_max(r.min, pos2(r.right(), r.top() + BIG_ICON + 8.0));
+    let resp = ui.interact(if variants.is_empty() { r } else { top }, ui.scope_id().with(("rb_big", cmd.id, cmd.label)), Sense::click());
+    hover_fill(ui, if variants.is_empty() { r } else { top }, &resp);
+    paint_icon(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.top() + 4.0 + BIG_ICON / 2.0), vec2(BIG_ICON, BIG_ICON)), cmd, !enabled);
+    let (a, b) = two_lines(cmd.label);
+    let col = if enabled { t.text } else { t.text_faint };
+    let y = r.top() + BIG_ICON + 10.0;
+    ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, &a, font(), col);
+    if let Some(b) = &b {
+        ui.painter().text(pos2(r.center().x, y + 13.0), Align2::CENTER_TOP, b, font(), col);
+    }
+    icons::describe_control(ui, &resp, cmd.label, None, false);
+    if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {
+        *out = Some(Action::Start(cmd.id));
+    }
+    if !variants.is_empty() {
+        let low = Rect::from_min_max(pos2(r.left(), top.bottom()), r.max);
+        let vr = ui.interact(low, ui.scope_id().with(("rb_bigv", cmd.id, cmd.label)), Sense::click());
+        hover_fill(ui, low, &vr);
+        let line_y = y + if b.is_some() { 26.0 } else { 13.0 };
+        icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, line_y + 4.0), vec2(9.0, 9.0)), Icon::ChevronDown, false);
+        egui::Popup::menu(&vr).show(|ui| variant_menu(ui, variants, out));
+    }
+}
+
+fn small(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, variants: &'static [Cmd], out: &mut Option<Action>) {
+    let t = Tokens::get();
+    let enabled = known(cmd.id);
+    let main = if variants.is_empty() { r } else { Rect::from_min_max(r.min, pos2(r.right() - 12.0, r.bottom())) };
+    let resp = ui.interact(main, ui.scope_id().with(("rb_small", cmd.id, cmd.label, r.top() as i32)), Sense::click());
+    hover_fill(ui, main, &resp);
+    paint_icon(
+        ui.painter(),
+        Rect::from_center_size(pos2(r.left() + 2.0 + SMALL_ICON / 2.0, r.center().y), vec2(SMALL_ICON, SMALL_ICON)),
+        cmd,
+        !enabled,
+    );
+    ui.painter().text(
+        pos2(r.left() + SMALL_ICON + 6.0, r.center().y),
+        Align2::LEFT_CENTER,
+        crate::i18n::ribbon(cmd.label),
+        font(),
+        if enabled { t.text } else { t.text_faint },
+    );
+    icons::describe_control(ui, &resp, cmd.label, None, false);
+    if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {
+        *out = Some(Action::Start(cmd.id));
+    }
+    if !variants.is_empty() {
+        let vr_rect = Rect::from_min_max(pos2(r.right() - 12.0, r.top()), r.max);
+        let vr = ui.interact(vr_rect, ui.scope_id().with(("rb_smallv", cmd.id, cmd.label, r.top() as i32)), Sense::click());
+        hover_fill(ui, vr_rect, &vr);
+        icons::paint(ui.painter(), Rect::from_center_size(vr_rect.center(), vec2(8.0, 8.0)), Icon::ChevronDown, false);
+        egui::Popup::menu(&vr).show(|ui| variant_menu(ui, variants, out));
+    }
+}
+
+fn icon_button(ui: &mut egui::Ui, r: Rect, cmd: &Cmd, out: &mut Option<Action>) {
+    let enabled = known(cmd.id);
+    let resp = ui.interact(r, ui.scope_id().with(("rb_icon", cmd.id, cmd.label)), Sense::click());
+    hover_fill(ui, r, &resp);
+    paint_icon(ui.painter(), Rect::from_center_size(r.center(), vec2(SMALL_ICON, SMALL_ICON)), cmd, !enabled);
+    icons::describe_control(ui, &resp, cmd.label, None, false);
+    if resp.on_hover_text(tooltip(cmd, enabled)).clicked() && enabled {
+        *out = Some(Action::Start(cmd.id));
+    }
+}
+
+/// The current value a list shows, and the command its button opens.
+fn widget_value(app: &CadApp, w: Widget) -> (String, &'static str) {
+    let Ok(d) = app.session.doc() else { return (String::new(), "ui.noop") };
+    let h = &d.header;
+    let get = |name: &str, default: &str| {
+        let v = h.str(name, default);
+        if v.is_empty() { default.to_string() } else { v }
+    };
+    match w {
+        Widget::Layer => (h.str("CLAYER", "0"), "ui.dialog.layers"),
+        Widget::Color => (color_name(h.i64("CECOLOR", 256)), "color"),
+        Widget::Lineweight => (lineweight_name(h.i64("CELWEIGHT", -1)), "lweight"),
+        Widget::Linetype => (by_name(&get("CELTYPE", "ByLayer")), "linetype"),
+        Widget::TextStyle => (get("TEXTSTYLE", "Standard"), "style"),
+        Widget::DimStyle => (get("DIMSTYLE", "Standard"), "dimstyle"),
+        Widget::MleaderStyle => (get("CMLEADERSTYLE", "Standard"), "mleaderstyle"),
+        Widget::TableStyle => (get("CTABLESTYLE", "Standard"), "tablestyle"),
+        Widget::NamedView => (crate::tl!("Unsaved View").to_string(), "view"),
+    }
+}
+
+/// ByLayer/ByBlock in the interface language; other names as they are.
+fn by_name(name: &str) -> String {
+    if name.eq_ignore_ascii_case("ByLayer") {
+        crate::tl!("ByLayer").to_string()
+    } else if name.eq_ignore_ascii_case("ByBlock") {
+        crate::tl!("ByBlock").to_string()
+    } else {
+        name.to_string()
+    }
+}
+
+/// The usual CAD name for a colour index (CECOLOR).
+pub fn color_name(aci: i64) -> String {
+    match aci {
+        256 => crate::tl!("ByLayer").to_string(),
+        0 => crate::tl!("ByBlock").to_string(),
+        1 => crate::tl!("Red").to_string(),
+        2 => crate::tl!("Yellow").to_string(),
+        3 => crate::tl!("Green").to_string(),
+        4 => crate::tl!("Cyan").to_string(),
+        5 => crate::tl!("Blue").to_string(),
+        6 => crate::tl!("Magenta").to_string(),
+        7 => crate::tl!("White").to_string(),
+        n => format!("{} {n}", crate::tl!("Color")),
+    }
+}
+
+/// The usual CAD name for a lineweight value (CELWEIGHT, hundredths of a millimetre).
+pub fn lineweight_name(v: i64) -> String {
+    match v {
+        -1 => crate::tl!("ByLayer").to_string(),
+        -2 => crate::tl!("ByBlock").to_string(),
+        -3 => crate::tl!("Default").to_string(),
+        n if n >= 0 => format!("{:.2} mm", n as f64 / 100.0),
+        _ => crate::tl!("ByLayer").to_string(),
+    }
+}
+
+fn list(ui: &mut egui::Ui, app: &CadApp, r: Rect, w: Widget, out: &mut Option<Action>) {
+    let t = Tokens::get();
+    let (value, cmd) = widget_value(app, w);
+    let resp = ui.interact(r, ui.scope_id().with(("rb_list", w as u8)), Sense::click());
+    ui.painter().rect_filled(r, 2.0, if resp.hovered() { t.control_hover } else { t.control });
+    let mut x = r.left() + 5.0;
+    if w == Widget::Layer
+        && let Ok(d) = app.session.doc()
+        && let Some(l) = d.layers.iter().find(|l| l.name.eq_ignore_ascii_case(&value))
+    {
+        for icon in [
+            if l.on { Icon::Bulb } else { Icon::BulbOff },
+            if l.frozen { Icon::Snowflake } else { Icon::Sun },
+            if l.locked { Icon::Lock } else { Icon::Unlock },
+        ] {
+            icons::paint(ui.painter(), Rect::from_min_size(pos2(x, r.center().y - 7.0), vec2(14.0, 14.0)), icon, false);
+            x += 16.0;
+        }
+        crate::layers::swatch(ui.painter(), Rect::from_min_size(pos2(x + 1.0, r.center().y - 5.0), vec2(10.0, 10.0)), l.color);
+        x += 16.0;
+    }
+    let galley = ui.painter().layout_no_wrap(value.clone(), font(), t.text);
+    let room = (r.right() - 16.0 - x).max(0.0);
+    let clip = Rect::from_min_max(pos2(x, r.top()), pos2(x + room, r.bottom()));
+    ui.painter().with_clip_rect(clip).galley(pos2(x, r.center().y - galley.size().y / 2.0), galley, t.text);
+    icons::paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 8.0, r.center().y), vec2(9.0, 9.0)), Icon::ChevronDown, false);
+    resp.clone().on_hover_text(&value);
+    if w == Widget::Layer {
+        let layers: Vec<(String, bool, bool, bool, cadcraft_color::Color)> =
+            app.session.doc().map(|d| d.layers.iter().map(|l| (l.name.clone(), l.on, l.frozen, l.locked, l.color)).collect()).unwrap_or_default();
+        egui::Popup::from_toggle_button_response(&resp)
+            .width(r.width().max(220.0))
+            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
+            .show(|ui| {
+                egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
+                    for (name, on, frozen, locked, color) in &layers {
+                        ui.horizontal(|ui| {
+                            if icons::button(ui, if *on { Icon::Bulb } else { Icon::BulbOff }, 18.0, "On/Off", false).clicked() {
+                                *out = Some(Action::LayerToggle(name.clone(), "on", !*on));
+                            }
+                            if icons::button(ui, if *frozen { Icon::Snowflake } else { Icon::Sun }, 18.0, "Freeze/Thaw", false).clicked() {
+                                *out = Some(Action::LayerToggle(name.clone(), "frozen", !*frozen));
+                            }
+                            if icons::button(ui, if *locked { Icon::Lock } else { Icon::Unlock }, 18.0, "Lock/Unlock", false).clicked() {
+                                *out = Some(Action::LayerToggle(name.clone(), "locked", !*locked));
+                            }
+                            let (sr, _) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::hover());
+                            crate::layers::swatch(ui.painter(), sr, *color);
+                            if ui.selectable_label(name.eq_ignore_ascii_case(&value), name).clicked() {
+                                *out = Some(Action::Layer(name.clone()));
+                                ui.close();
+                            }
+                        });
+                    }
+                });
+            });
+    } else if resp.clicked() && known(cmd) {
+        *out = Some(Action::Start(cmd));
+    }
+}
+
+fn draw_cell(ui: &mut egui::Ui, app: &CadApp, cell: &Cell, r: Rect, out: &mut Option<Action>) {
+    match cell {
+        Cell::Big(cmd, v) => big(ui, r.shrink2(vec2(PAD / 2.0, 0.0)), cmd, v, out),
+        Cell::Small(rows) => {
+            for (i, (cmd, v)) in rows.iter().enumerate() {
+                let rr = Rect::from_min_size(pos2(r.left() + PAD, r.top() + i as f32 * ROW + 1.0), vec2(r.width() - 2.0 * PAD, ROW - 2.0));
+                small(ui, rr, cmd, v, out);
+            }
+        }
+        Cell::Icons(cmds) => {
+            for (i, cmd) in cmds.iter().enumerate() {
+                let (col, row) = (i / 3, i % 3);
+                let rr = Rect::from_min_size(
+                    pos2(r.left() + col as f32 * (SMALL_ICON + 8.0), r.top() + row as f32 * ROW + 1.0),
+                    vec2(SMALL_ICON + 6.0, ROW - 2.0),
+                );
+                icon_button(ui, rr, cmd, out);
+            }
+        }
+        Cell::Lists(ws, w) => {
+            for (i, wd) in ws.iter().enumerate() {
+                let rr = Rect::from_min_size(pos2(r.left() + PAD, r.top() + i as f32 * ROW + 2.0), vec2(*w, ROW - 4.0));
+                list(ui, app, rr, *wd, out);
+            }
+        }
+        Cell::ListIcons(wd, w, cmds) => {
+            list(ui, app, Rect::from_min_size(pos2(r.left() + PAD, r.top() + 2.0), vec2(*w, ROW - 4.0)), *wd, out);
+            let per_row = ((*w / (SMALL_ICON + 8.0)).floor() as usize).max(1);
+            for (i, cmd) in cmds.iter().enumerate() {
+                let (row, col) = (1 + i / per_row, i % per_row);
+                let rr = Rect::from_min_size(
+                    pos2(r.left() + PAD + col as f32 * (SMALL_ICON + 8.0), r.top() + row as f32 * ROW + 1.0),
+                    vec2(SMALL_ICON + 6.0, ROW - 2.0),
+                );
+                icon_button(ui, rr, cmd, out);
+            }
+        }
+    }
+}
+
+/// Every command of a panel, for its collapsed button's menu.
+fn panel_commands(panel: &PanelDef) -> Vec<Cmd> {
+    let mut v = Vec::new();
+    for cell in panel.cells {
+        match cell {
+            Cell::Big(cmd, _) => v.push(*cmd),
+            Cell::Small(rows) => v.extend(rows.iter().map(|(cmd, _)| *cmd)),
+            Cell::Icons(cmds) => v.extend(cmds.iter().copied()),
+            Cell::Lists(..) => {}
+            Cell::ListIcons(_, _, cmds) => v.extend(cmds.iter().copied()),
+        }
+    }
+    v.extend(panel.more.iter().copied());
+    v
+}
+
+/// The ribbon (tab row and the active tab's panels).
+pub fn ribbon(app: &mut CadApp, ui: &mut egui::Ui) {
+    let t = Tokens::get();
+    let h = if app.ui.ribbon_minimized { TAB_ROW + 2.0 } else { HEIGHT };
+    let mut out: Option<Action> = None;
+    egui::Panel::top("cc_ribbon").exact_size(h).frame(egui::Frame::NONE.fill(t.chrome)).show(ui, |ui| {
+        let r = ui.max_rect();
+        let background = ui.interact(r, ui.scope_id().with("ribbon_customization"), Sense::click());
+        let active = TABS
+            .iter()
+            .find(|tab| tab.id == app.ui.ribbon_tab && app.ui.ribbon_visibility.tab_visible(tab.id))
+            .or_else(|| TABS.iter().find(|tab| app.ui.ribbon_visibility.tab_visible(tab.id)))
+            .map(|tab| tab.id);
+        let customize_rect = Rect::from_min_size(pos2(r.right() - 28.0, r.top()), vec2(24.0, TAB_ROW));
+        let customize = ui.interact(customize_rect, ui.scope_id().with("ribbon_customize_button"), Sense::click());
+        icons::paint(ui.painter(), customize_rect.shrink(5.0), Icon::Menu, false);
+        icons::describe_control(ui, &customize, "Ribbon Customization", None, false);
+        egui::Popup::menu(&customize).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| visibility::menu(app, ui, active));
+        egui::Popup::context_menu(&background)
+            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
+            .show(|ui| visibility::menu(app, ui, active));
+        // Tabs.
+        let mut x = r.left() + 8.0;
+        let tab_clip = Rect::from_min_max(r.min, pos2(customize_rect.left() - 2.0, r.top() + TAB_ROW));
+        let tab_painter = ui.painter().with_clip_rect(tab_clip.intersect(ui.clip_rect()));
+        for tab in TABS {
+            if !app.ui.ribbon_visibility.tab_visible(tab.id) {
+                continue;
+            }
+            let label = tab_label(tab.title);
+            let w = ui.painter().layout_no_wrap(label.clone(), crate::theme::body(), t.text).size().x + 18.0;
+            let tr = Rect::from_min_size(pos2(x, r.top()), vec2(w, TAB_ROW));
+            if tr.left() >= tab_clip.right() {
+                break;
+            }
+            let resp = ui.interact(tr.intersect(tab_clip), ui.scope_id().with(("rb_tab", tab.id)), Sense::click());
+            let is_active = Some(tab.id) == active;
+            if is_active && !app.ui.ribbon_minimized {
+                tab_painter.rect_filled(tr, egui::CornerRadius { nw: 3, ne: 3, sw: 0, se: 0 }, t.panel);
+            } else if resp.hovered() {
+                tab_painter.rect_filled(tr, 3.0, t.control_hover.gamma_multiply(0.5));
+            }
+            tab_painter.text(tr.center(), Align2::CENTER_CENTER, label, crate::theme::body(), if is_active { t.text } else { t.text_dim });
+            icons::describe_control(ui, &resp, tab.title, Some(is_active), true);
+            if resp.double_clicked() {
+                out = Some(Action::ToggleMinimized);
+            } else if resp.clicked() {
+                out = Some(Action::Tab(tab.id));
+            }
+            x += w + 2.0;
+        }
+        if app.ui.ribbon_minimized {
+            return;
+        }
+        // Body.
+        let body = Rect::from_min_max(pos2(r.left(), r.top() + TAB_ROW), r.max);
+        ui.painter().rect_filled(body, 0.0, t.panel);
+        ui.painter().hline(body.x_range(), body.bottom() - 0.5, Stroke::new(1.0, t.border));
+        let Some(tab) = TABS.iter().find(|tab| Some(tab.id) == active) else { return };
+        let panels: Vec<_> = tab.panels.iter().filter(|p| app.ui.ribbon_visibility.panel_visible(tab.id, p.title)).collect();
+        let full: Vec<f32> = panels.iter().map(|p| panel_width(ui, p)).collect();
+        let collapsed: Vec<f32> = panels.iter().map(|p| collapsed_width(ui, p)).collect();
+        let expanded = fit(&full, &collapsed, body.width() - 12.0);
+        let mut x = body.left() + 6.0;
+        for (i, panel) in panels.iter().enumerate() {
+            let open = expanded.get(i).copied().unwrap_or(true);
+            let w = if open { full.get(i).copied().unwrap_or(0.0) } else { collapsed.get(i).copied().unwrap_or(0.0) };
+            let pr = Rect::from_min_size(pos2(x, body.top() + 2.0), vec2(w, BODY + TITLE));
+            if open {
+                let mut cx = pr.left() + PAD;
+                for cell in panel.cells {
+                    let cw = cell_width(ui, cell);
+                    draw_cell(ui, app, cell, Rect::from_min_size(pos2(cx, pr.top()), vec2(cw, BODY)), &mut out);
+                    cx += cw;
+                }
+                // Title row (with the expander when the panel has more commands).
+                let title_r = Rect::from_min_max(pos2(pr.left(), pr.bottom() - TITLE), pr.max);
+                let label = crate::i18n::ribbon(panel.title);
+                if panel.more.is_empty() {
+                    ui.painter().text(title_r.center(), Align2::CENTER_CENTER, label, font(), t.text_dim);
+                } else {
+                    let resp = ui.interact(title_r, ui.scope_id().with(("rb_more", tab.id, panel.title)), Sense::click());
+                    hover_fill(ui, title_r, &resp);
+                    let tw = text_w(ui, panel.title);
+                    ui.painter().text(pos2(title_r.center().x - 6.0, title_r.center().y), Align2::CENTER_CENTER, label, font(), t.text_dim);
+                    icons::paint(
+                        ui.painter(),
+                        Rect::from_center_size(pos2(title_r.center().x + tw / 2.0 + 2.0, title_r.center().y), vec2(8.0, 8.0)),
+                        Icon::ChevronDown,
+                        false,
+                    );
+                    icons::describe_control(ui, &resp, panel.title, None, false);
+                    egui::Popup::menu(&resp).show(|ui| variant_menu(ui, panel.more, &mut out));
+                }
+            } else {
+                let resp = ui.interact(pr, ui.scope_id().with(("rb_collapsed", tab.id, panel.title)), Sense::click());
+                hover_fill(ui, pr, &resp);
+                let first = panel_commands(panel).into_iter().next();
+                if let Some(cmd) = &first {
+                    paint_icon(
+                        ui.painter(),
+                        Rect::from_center_size(pos2(pr.center().x, pr.top() + 6.0 + BIG_ICON / 2.0), vec2(BIG_ICON, BIG_ICON)),
+                        cmd,
+                        false,
+                    );
+                }
+                ui.painter().text(
+                    pos2(pr.center().x, pr.top() + BIG_ICON + 14.0),
+                    Align2::CENTER_TOP,
+                    crate::i18n::ribbon(panel.title),
+                    font(),
+                    t.text,
+                );
+                icons::paint(ui.painter(), Rect::from_center_size(pos2(pr.center().x, pr.bottom() - 10.0), vec2(9.0, 9.0)), Icon::ChevronDown, false);
+                icons::describe_control(ui, &resp, panel.title, None, false);
+                let cmds = panel_commands(panel);
+                egui::Popup::menu(&resp).show(|ui| variant_menu(ui, &cmds, &mut out));
+            }
+            x += w;
+            // Panel separator.
+            ui.painter().vline(x, (pr.top() + 4.0)..=(pr.bottom() - 4.0), Stroke::new(1.0, t.border));
+            x += 1.0;
+        }
+    });
+    match out {
+        Some(Action::Start(id)) => app.start(id),
+        Some(Action::Tab(id)) => {
+            app.ui.ribbon_tab = id.to_string();
+            app.ui.ribbon_minimized = false;
+        }
+        Some(Action::ToggleMinimized) => app.ui.ribbon_minimized = !app.ui.ribbon_minimized,
+        Some(Action::Layer(name)) => {
+            let _ = app.run("layer.current", json!({ "name": name }));
+        }
+        Some(Action::LayerToggle(name, key, value)) => {
+            let _ = app.run("layer.set", json!({ "name": name, key: value }));
+        }
+        None => {}
+    }
+}
+
+/// Switch to the Windows layout: Ribbon, quick access tool bar in the title row,
+/// Properties docked left, no Tool Sets or Layers palette.
+pub fn apply_pc_layout(app: &mut CadApp) {
+    let workspace = crate::docking::pc_workspace();
+    crate::docking::publish_visibility(app, &workspace);
+    app.ui.docking = workspace;
+    app.ui.show_ribbon = true;
+    app.ui.show_toolbar = false;
+    app.ui.pc_layout_applied = true;
+}
+
+/// Back to CADCraft's Mac layout: Tool Bar, Tool Sets, Layers and Properties
+/// palettes on the right, no Ribbon.
+pub fn apply_mac_layout(app: &mut CadApp) {
+    let workspace = crate::docking::default_workspace();
+    crate::docking::publish_visibility(app, &workspace);
+    app.ui.docking = workspace;
+    app.ui.show_ribbon = false;
+    app.ui.show_toolbar = true;
+    app.ui.pc_layout_applied = true;
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn panels_collapse_from_the_right() {
+        let full = [100.0, 100.0, 100.0];
+        let collapsed = [40.0, 40.0, 40.0];
+        assert_eq!(fit(&full, &collapsed, 400.0), vec![true, true, true]);
+        assert_eq!(fit(&full, &collapsed, 250.0), vec![true, true, false]);
+        assert_eq!(fit(&full, &collapsed, 190.0), vec![true, false, false]);
+        assert_eq!(fit(&full, &collapsed, 10.0), vec![false, false, false]);
+    }
+
+    #[test]
+    fn tabs_match_the_windows_layout_order() {
+        let titles: Vec<&str> = TABS.iter().map(|t| t.title).collect();
+        assert_eq!(titles, ["Home", "Insert", "Annotate", "Parametric", "View", "Manage", "Output", "Collaborate"]);
+        let home: Vec<&str> = TABS[0].panels.iter().map(|p| p.title).collect();
+        assert_eq!(home, ["Draw", "Modify", "Annotation", "Layers", "Block", "Properties", "Groups", "Utilities", "Clipboard"]);
+    }
+
+    /// The everyday drafting commands of the Home tab are all real commands (not greyed out).
+    #[test]
+    fn home_tab_core_commands_exist() {
+        for id in [
+            "line",
+            "pline",
+            "circle",
+            "arc",
+            "rectang",
+            "ellipse",
+            "hatch",
+            "move",
+            "copy",
+            "stretch",
+            "rotate",
+            "mirror",
+            "scale",
+            "trim",
+            "fillet",
+            "arrayrect",
+            "erase",
+            "explode",
+            "offset",
+            "mtext",
+            "dim",
+            "dimlinear",
+            "mleader",
+            "table",
+            "insert",
+            "block",
+            "matchprop",
+            "laymcur",
+            "layoff",
+            "layiso",
+            "layfrz",
+            "laylck",
+        ] {
+            assert!(known(id), "{id} is not a command");
+        }
+        for v in CIRCLES.iter().chain(ARCS).chain(ELLIPSES) {
+            assert!(known(v.id), "{} is not a command", v.id);
+        }
+    }
+
+    #[test]
+    fn colour_and_lineweight_names() {
+        assert_eq!(lineweight_name(30), "0.30 mm");
+        assert_eq!(lineweight_name(-1), "ByLayer");
+        assert_eq!(color_name(4), "Cyan");
+        assert_eq!(color_name(42), "Color 42");
+    }
+}
diff --git a/crates/ui-egui/src/ribbon/visibility.rs b/crates/ui-egui/src/ribbon/visibility.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/ribbon/visibility.rs
@@ -0,0 +1,109 @@
+//! Validated ribbon visibility preferences and command-backed customization.
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+use super::TABS;
+use crate::CadApp;
+
+/// Canonical tab ids and English panel titles identify hidden items, never translated labels.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
+pub struct Visibility {
+    pub hidden_tabs: Vec<String>,
+    pub hidden_panels: Vec<(String, String)>,
+}
+
+impl Visibility {
+    /// Reject unknown, duplicate and excessive entries atomically.
+    pub fn validate(&self) -> Result<(), String> {
+        if self.hidden_tabs.len() > TABS.len() || self.hidden_panels.len() > TABS.iter().map(|t| t.panels.len()).sum::<usize>() {
+            return Err("ribbon visibility exceeds the catalog size".into());
+        }
+        for (i, id) in self.hidden_tabs.iter().enumerate() {
+            if !TABS.iter().any(|t| t.id == id) || self.hidden_tabs[..i].contains(id) {
+                return Err("unknown or duplicate ribbon tab".into());
+            }
+        }
+        for (i, key) in self.hidden_panels.iter().enumerate() {
+            if !TABS.iter().any(|t| t.id == key.0 && t.panels.iter().any(|p| p.title == key.1)) || self.hidden_panels[..i].contains(key) {
+                return Err("unknown or duplicate ribbon panel".into());
+            }
+        }
+        Ok(())
+    }
+
+    /// Whether a catalog tab is available in the ribbon tab row.
+    pub fn tab_visible(&self, id: &str) -> bool {
+        !self.hidden_tabs.iter().any(|hidden| hidden == id)
+    }
+
+    /// Whether a panel is available within its owning tab.
+    pub fn panel_visible(&self, tab: &str, title: &str) -> bool {
+        !self.hidden_panels.iter().any(|(t, p)| t == tab && p == title)
+    }
+}
+
+/// Handle UI-only visibility commands without starting or cancelling an engine command.
+pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
+    if id == "ui.ribbon.visibility.reset" {
+        app.ui.ribbon_visibility = Visibility::default();
+        return Some(Ok(Value::Null));
+    }
+    if id != "ui.ribbon.visibility" {
+        return None;
+    }
+    Some((|| {
+        let tab = params.get("tab").and_then(Value::as_str).ok_or("tab is required")?;
+        let on = params.get("on").and_then(Value::as_bool).ok_or("on must be a boolean")?;
+        let mut next = app.ui.ribbon_visibility.clone();
+        if let Some(panel) = params.get("panel") {
+            let panel = panel.as_str().ok_or("panel must be a string")?;
+            let key = (tab.to_string(), panel.to_string());
+            next.hidden_panels.retain(|hidden| hidden != &key);
+            // Validate even a request to show an unknown panel.
+            if !TABS.iter().any(|t| t.id == tab && t.panels.iter().any(|p| p.title == panel)) {
+                return Err("unknown ribbon panel".into());
+            }
+            if !on {
+                next.hidden_panels.push(key);
+            }
+        } else {
+            if !TABS.iter().any(|t| t.id == tab) {
+                return Err("unknown ribbon tab".into());
+            }
+            next.hidden_tabs.retain(|hidden| hidden != tab);
+            if !on {
+                next.hidden_tabs.push(tab.to_string());
+            }
+        }
+        next.validate()?;
+        app.ui.ribbon_visibility = next;
+        Ok(Value::Null)
+    })())
+}
+
+/// The same menu serves the ribbon background and its always-visible recovery button.
+pub fn menu(app: &mut CadApp, ui: &mut egui::Ui, active: Option<&str>) {
+    if ui.button(crate::tl!("Show All Ribbon Items")).clicked() {
+        let _ = app.run("ui.ribbon.visibility.reset", json!({}));
+    }
+    ui.menu_button(crate::tl!("Show Tabs"), |ui| {
+        for tab in TABS {
+            let mut on = app.ui.ribbon_visibility.tab_visible(tab.id);
+            if ui.checkbox(&mut on, super::tab_label(tab.title)).changed() {
+                let _ = app.run("ui.ribbon.visibility", json!({"tab":tab.id,"on":on}));
+            }
+        }
+    });
+    if let Some(tab) = TABS.iter().find(|tab| Some(tab.id) == active) {
+        ui.menu_button(crate::tl!("Show Panels"), |ui| {
+            for panel in tab.panels {
+                let mut on = app.ui.ribbon_visibility.panel_visible(tab.id, panel.title);
+                if ui.checkbox(&mut on, crate::i18n::ribbon(panel.title)).changed() {
+                    let _ = app.run("ui.ribbon.visibility", json!({"tab":tab.id,"panel":panel.title,"on":on}));
+                }
+            }
+        });
+    }
+}
diff --git a/crates/ui-egui/tests/it/chrome_geometry.rs b/crates/ui-egui/tests/it/chrome_geometry.rs
--- a/crates/ui-egui/tests/it/chrome_geometry.rs
+++ b/crates/ui-egui/tests/it/chrome_geometry.rs
@@ -8,9 +8,11 @@ use egui::{Rect, Shape, vec2};
 use egui_kittest::Harness;
 use egui_kittest::kittest::Queryable;
 
-fn harness(width: f32, height: f32, scale: f32, message: bool) -> Harness<'static, CadApp> {
+/// `pc`: the Windows layout (Ribbon); else the Mac layout (Tool Bar).
+fn harness(width: f32, height: f32, scale: f32, message: bool, pc: bool) -> Harness<'static, CadApp> {
     let mut app = CadApp::new(Session::default(), Services::default());
     app.run("new", serde_json::json!({})).unwrap();
+    app.run(if pc { "ui.layout.pc" } else { "ui.layout.mac" }, serde_json::json!({})).unwrap();
     app.ui.start_tab = false;
     app.ui.in_window_menu = false;
     app.integrated_titlebar = true;
@@ -46,11 +48,11 @@ fn visible_text(shape: &Shape, clip: Rect, result: &mut Vec<(String, Rect)>) {
     }
 }
 
-fn check_status_labels_case(width: f32, height: f32, scale: f32, message: bool, overlaps: &mut Vec<String>) {
-    let mut h = harness(width, height, scale, message);
+fn check_status_labels_case(width: f32, height: f32, scale: f32, message: bool, pc: bool, overlaps: &mut Vec<String>) {
+    let mut h = harness(width, height, scale, message, pc);
     if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") {
         std::fs::create_dir_all(&directory).unwrap();
-        h.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{message}.png")).unwrap();
+        h.render().unwrap().save(format!("{directory}/cad-{width}-{scale}-{message}-{}.png", if pc { "pc" } else { "mac" })).unwrap();
     }
     let mut texts = Vec::new();
     for shape in &h.output().shapes {
@@ -76,8 +78,8 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() {
     let mut overlaps = Vec::new();
     for (width, height) in [(1200.0, 760.0), (900.0, 560.0)] {
         for scale in [1.0, 2.0] {
-            for message in [false, true] {
-                check_status_labels_case(width, height, scale, message, &mut overlaps);
+            for (message, pc) in [(false, false), (true, false), (false, true), (true, true)] {
+                check_status_labels_case(width, height, scale, message, pc, &mut overlaps);
             }
         }
     }
@@ -87,7 +89,7 @@ fn status_labels_never_overlap_at_standard_and_compact_sizes() {
 #[test]
 fn layout_tabs_stay_anchored_when_toolsets_toggle_resize_or_move() {
     for scale in [1.0, 2.0] {
-        let mut h = harness(1200.0, 760.0, scale, false);
+        let mut h = harness(1200.0, 760.0, scale, false, false);
         let tab_rects =
             |h: &Harness<'_, CadApp>| ["Model", "Layout1", "Layout2"].map(|name| h.get_by_role_and_label(egui::accesskit::Role::Tab, name).rect());
         let original = tab_rects(&h);
@@ -114,7 +116,7 @@ fn layout_tabs_stay_anchored_when_toolsets_toggle_resize_or_move() {
 #[test]
 fn layout_tabs_and_add_button_work_with_toolsets_shown_or_hidden() {
     for visible in [true, false] {
-        let mut h = harness(1400.0, 760.0, 1.0, false);
+        let mut h = harness(1400.0, 760.0, 1.0, false, false);
         h.state_mut().run("ui.toggle.toolsets", serde_json::json!({"on":visible})).unwrap();
         h.run_steps(5);
         h.get_by_role_and_label(egui::accesskit::Role::Tab, "Layout1").click();
@@ -137,7 +139,7 @@ fn layout_tabs_and_add_button_work_with_toolsets_shown_or_hidden() {
 
 #[test]
 fn compact_toolbar_overflow_keeps_help_reachable() {
-    let mut h = harness(900.0, 560.0, 1.0, false);
+    let mut h = harness(900.0, 560.0, 1.0, false, false);
     h.get_by_label("More toolbar commands").click();
     h.run_steps(3);
     h.get_by_label("Help").click();
@@ -147,8 +149,54 @@ fn compact_toolbar_overflow_keeps_help_reachable() {
 
 #[test]
 fn toolbar_plot_preview_opens_dialog() {
-    let mut h = harness(1200.0, 760.0, 1.0, false);
+    let mut h = harness(1200.0, 760.0, 1.0, false, false);
     h.get_by_label("Plot Preview").click();
     h.run_steps(3);
     assert_eq!(h.state().ui.dialog.as_deref(), Some("preview"));
 }
+
+#[test]
+fn ribbon_output_tab_preview_opens_dialog() {
+    let mut h = harness(1200.0, 760.0, 1.0, false, true);
+    h.get_by_label("Output").click();
+    h.run_steps(3);
+    assert_eq!(h.state().ui.ribbon_tab, "output");
+    h.get_by_label("Preview").click();
+    h.run_steps(3);
+    assert_eq!(h.state().ui.dialog.as_deref(), Some("preview"));
+}
+
+#[test]
+fn ribbon_home_tab_starts_line() {
+    let mut h = harness(1200.0, 760.0, 1.0, false, true);
+    h.get_by_label("Line").click();
+    h.run_steps(3);
+    assert!(h.state().session.running.is_some(), "LINE should be prompting");
+}
+
+#[test]
+fn the_in_window_menu_bar_sits_above_the_ribbon() {
+    let mut h = harness(1200.0, 760.0, 1.0, false, true);
+    // Windows and Linux draw the menu bar in the window (macOS uses the system one).
+    h.state_mut().ui.in_window_menu = true;
+    h.run_steps(5);
+    let mut texts = Vec::new();
+    for shape in &h.output().shapes {
+        visible_text(&shape.shape, shape.clip_rect, &mut texts);
+    }
+    let top = |label: &str| texts.iter().find(|(text, _)| text == label).map(|(_, rect)| rect.top()).unwrap_or_else(|| panic!("no {label:?} drawn"));
+    // The menu bar's File, then the Ribbon's Home tab under it.
+    assert!(top("File") < top("Home"), "File at {}, Home at {}", top("File"), top("Home"));
+}
+
+#[test]
+fn the_windows_layout_has_no_collapsed_tool_sets_strip() {
+    // The Ribbon holds the tools: no 22 px Tool Sets strip beside the Properties palette.
+    let h = harness(1200.0, 760.0, 1.0, false, true);
+    assert!(h.query_by_label("Expand Tool Sets").is_none());
+    // The Mac layout with Tool Sets hidden keeps its strip to bring them back.
+    let mut h = harness(1200.0, 760.0, 1.0, false, false);
+    h.state_mut().ui.show_toolsets = false;
+    h.run_steps(3);
+    assert!(h.query_by_label("Expand Tool Sets").is_some());
+}
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -24,6 +24,7 @@ mod parameters_rename;
 mod pickdrag;
 mod properties_units;
 mod rapid_clicks;
+mod ribbon_visibility;
 mod selection_cycling;
 mod shared_controls;
 mod shortcut_menus;
diff --git a/crates/ui-egui/tests/it/ribbon_visibility.rs b/crates/ui-egui/tests/it/ribbon_visibility.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/ribbon_visibility.rs
@@ -0,0 +1,105 @@
+//! Ribbon visibility is independent of drawing state and command execution.
+use cadcraft_engine::Session;
+use cadcraft_ui_egui::{CadApp, Services, ribbon::TABS};
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use serde_json::json;
+
+/// Invalid remote customization must leave every UI preference unchanged.
+#[test]
+fn ui_set_rejects_invalid_ribbon_visibility_atomically() {
+    use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle};
+    let mut app = CadApp::new(Session::default(), Services::default());
+    let ctx = egui::Context::default();
+    let (request, _) = ControlRequest::new("ui.set", json!({"ribbonVisibility":{"hiddenTabs":["home"]}}));
+    assert!(matches!(handle(&mut app, &ctx, &request), Outcome::Done(v) if v["ok"] == true));
+    let before = serde_json::to_value(&app.ui).unwrap();
+    for invalid in [
+        json!({"hiddenTabs":["home","home"]}),
+        json!({"hiddenTabs":["unknown"]}),
+        json!({"hiddenTabs":vec!["home"; TABS.len() + 1]}),
+        json!({"hiddenTabs":"home"}),
+        json!({"hiddenPanels":[["home","Draw"],["home","Draw"]]}),
+        json!({"hiddenPanels":[["home","unknown"]]}),
+        json!({"unexpected":true}),
+    ] {
+        let (request, _) = ControlRequest::new("ui.set", json!({"ribbonVisibility":invalid,"showToolbar":!app.ui.show_toolbar}));
+        assert!(matches!(handle(&mut app, &ctx, &request), Outcome::Done(v) if v["ok"] == false));
+        assert_eq!(serde_json::to_value(&app.ui).unwrap(), before);
+    }
+    let mut restored = CadApp::new(Session::default(), Services::default());
+    restored.load_prefs(&app.prefs_json());
+    assert_eq!(restored.ui.ribbon_visibility.hidden_tabs, ["home"]);
+}
+
+#[test]
+fn visibility_is_atomic_persistent_and_recoverable_without_cancelling_line() {
+    let mut app = CadApp::new(Session::default(), Services::default());
+    app.session.start("line").unwrap();
+    let before = app.session.doc().unwrap().clone();
+    app.run("ui.ribbon.visibility", json!({"tab":"home","panel":"Draw","on":false})).unwrap();
+    assert!(!app.ui.ribbon_visibility.panel_visible("home", "Draw"));
+    for tab in TABS {
+        app.run("ui.ribbon.visibility", json!({"tab":tab.id,"on":false})).unwrap();
+    }
+    let hidden = app.ui.ribbon_visibility.clone();
+    for params in [json!({"tab":"missing","on":true}), json!({"tab":"home","panel":"missing","on":true}), json!({"tab":"home","on":"false"})] {
+        assert!(app.run("ui.ribbon.visibility", params).is_err());
+        assert_eq!(app.ui.ribbon_visibility, hidden);
+    }
+    let mut restored = CadApp::new(Session::default(), Services::default());
+    restored.load_prefs(&app.prefs_json());
+    assert_eq!(restored.ui.ribbon_visibility, hidden);
+    for malformed in [json!({"hiddenTabs":["home","home"]}), json!({"hiddenPanels":[["home","missing"]]}), json!({"hiddenTabs":"home"})] {
+        restored.load_prefs(&json!({"ribbonVisibility":malformed}).to_string());
+        assert_eq!(restored.ui.ribbon_visibility, hidden);
+    }
+    app.run("ui.resetpalettes", json!({})).unwrap();
+    assert_eq!(app.ui.ribbon_visibility, hidden);
+    app.run("ui.ribbon.visibility.reset", json!({})).unwrap();
+    assert!(TABS.iter().all(|tab| app.ui.ribbon_visibility.tab_visible(tab.id)));
+    assert!(app.ui.ribbon_visibility.panel_visible("home", "Draw"));
+    assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line"));
+    assert_eq!(app.session.doc().unwrap(), &before);
+}
+
+#[test]
+fn customization_remains_reachable_with_all_tabs_hidden() {
+    let mut app = CadApp::new(Session::default(), Services::default());
+    app.ui.show_ribbon = true;
+    for tab in TABS {
+        app.run("ui.ribbon.visibility", json!({"tab":tab.id,"on":false})).unwrap();
+    }
+    let mut h = Harness::builder().with_size(egui::vec2(1400.0, 800.0)).build_ui_state(
+        |ui, app: &mut CadApp| {
+            app.logic(&ui.ctx().clone());
+            app.ui(ui);
+        },
+        app,
+    );
+    h.run_steps(5);
+    h.get_by_label("Ribbon Customization").click();
+    h.run_steps(4);
+    h.get_by_label("Show All Ribbon Items").click();
+    h.run_steps(4);
+    assert!(TABS.iter().all(|tab| h.state().ui.ribbon_visibility.tab_visible(tab.id)));
+    assert!(h.state().ui.show_ribbon);
+}
+
+#[test]
+fn customization_remains_reachable_beside_tabs_in_a_narrow_window() {
+    let mut app = CadApp::new(Session::default(), Services::default());
+    app.ui.show_ribbon = true;
+    let mut h = Harness::builder().with_size(egui::vec2(500.0, 600.0)).build_ui_state(
+        |ui, app: &mut CadApp| {
+            app.logic(&ui.ctx().clone());
+            app.ui(ui);
+        },
+        app,
+    );
+    h.run_steps(5);
+    h.get_by_label("Ribbon Customization").click();
+    h.run_steps(4);
+    assert!(h.query_by_label("Show All Ribbon Items").is_some());
+    assert_eq!(h.state().ui.ribbon_tab, "home");
+}
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -128,6 +128,21 @@ value disables workspace reads and writes. An invalid, oversized or newer-versio
 reported and left untouched, with persistence disabled for that launch. Repair or move that
 file before restarting to resume saving workspace choices.
 
+## Ribbon visibility
+
+Use `engine.execute` with `ui.ribbon.visibility` and parameters
+`{"tab":"home","panel":"Draw","on":false}` to hide one panel. Omit `panel`
+to show or hide the whole tab. Tab IDs and canonical English panel titles come
+from the ribbon catalog; translated display labels are not identifiers.
+`ui.ribbon.visibility.reset` restores all catalog items. These commands preserve
+the drawing and any active drafting command.
+
+`ui.set` accepts `ribbonVisibility` with `hiddenTabs` and `hiddenPanels` (pairs of
+tab ID and panel title). Unknown, duplicate, excessive or malformed entries
+reject the whole UI update without changing unrelated fields. Valid visibility
+preferences persist in native and browser hosts and survive Reset Palettes.
+The Ribbon Customization button remains available when all tabs are hidden.
+
 ## Panel docking
 
 Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents.
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
 # UI parity
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (ribbon visibility customization and workspace recovery) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)
 
 How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic
 input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD
@@ -199,10 +199,27 @@ down-arrow options menu (DYNPROMPT), and DYNDIM.
   have menus too (#512). Still missing: in-place layout tab rename, Move or Copy, Select All
   Layouts.
 
+## Ribbon item visibility
+
+The tabbed ribbon supports command-backed visibility for tabs and their tool panels through
+its customization menu. The customization button remains reachable when every tab is hidden;
+Show All Ribbon Items restores the catalog. Visibility preferences persist independently of
+the drawing and palette layout. Invalid preferences and control updates are rejected atomically.
+Native workspace loading discards only malformed or obsolete ribbon visibility, preserving
+other saved preferences and subsequent saves; runtime control validation remains strict.
+
+This CC-014/024 contribution extends the existing #1023 ribbon proposal. It does not introduce
+a competing ribbon, native floating infrastructure, contextual tabs or auto-hide. Reverting
+the visibility contribution leaves the prerequisite ribbon usable with all items shown; its
+extra preference field can be ignored by the prior version. Any later feature using these
+visibility commands must document that dependency. Runtime and regression validation for this
+draft are pending; this section does not claim complete AutoCAD parity.
+
 ## Revision history
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Independent ribbon tab/panel visibility and recovery controls over #1023; Windows restart/all-hidden recovery and release-browser hide/reload/recovery verified. Browser layout selection still resets independently of item visibility. |
 | 2026-10-22 | minor | Polyline segment midpoint grips use rectangles, distinct from square vertex grips, with matching hover and pick bounds (#1950) |
 | 2026-10-13 | minor | Filled-region highlight boundaries are traversed in deterministic coordinate order, fixing dash-phase jumps on CPU hover/selection redraws and GPU highlight rebuilds; CPU dash-position, pixel and GPU mesh regressions |
 | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) |
diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs
--- a/xtask/src/parity.rs
+++ b/xtask/src/parity.rs
@@ -136,7 +136,9 @@ mod tests {
         assert_eq!(day(20_736), "2026-10-10");
         assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01");
         // Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).
-        assert_eq!(day(115_740_740), "318857-05-20");
+        // Checked on the day count: Windows' SystemTime ends in the year 30828, so building that
+        // SystemTime panics there.
+        assert_eq!(civil_from_days(115_740_740), (318_857, 5, 20));
         // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
         if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) {
             assert_eq!(date(far), "292277026596-12-04");
diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs
--- a/xtask/src/stats.rs
+++ b/xtask/src/stats.rs
@@ -134,7 +134,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
 
 #[cfg(test)]
 mod tests {
-    use super::{Counts, count_tests, walk};
+    use super::{Counts, count_tests};
 
     #[test]
     fn counts_attributes_and_proptest_blocks() {
@@ -155,7 +155,7 @@ mod tests {
         // A directory symlink pointing back at `real` would be re-scanned if followed.
         symlink(&real, base.join("alias")).unwrap();
         let mut c = Counts::default();
-        walk(&base, &mut c).unwrap();
+        super::walk(&base, &mut c).unwrap();
         assert_eq!(c.files, 1, "the aliased directory was followed and counted twice");
         std::fs::remove_dir_all(&base).ok();
     }
