lithop 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Cargo.lock +1 -1
  4. data/README.md +14 -11
  5. data/RELEASING.md +3 -3
  6. data/docs/README.md +4 -3
  7. data/docs/artifacts.md +27 -15
  8. data/docs/bambu-studio.md +12 -10
  9. data/docs/calibration.md +4 -4
  10. data/docs/color-model.md +20 -18
  11. data/docs/command.md +28 -12
  12. data/docs/config.md +42 -30
  13. data/docs/getting-started.md +12 -6
  14. data/docs/local-ui.md +12 -9
  15. data/docs/tiling.md +18 -19
  16. data/docs/troubleshooting.md +3 -3
  17. data/ext/lithop_native/Cargo.toml +1 -1
  18. data/lib/lithop/calibration/target.rb +5 -5
  19. data/lib/lithop/cli/image_adjustment_options.rb +33 -0
  20. data/lib/lithop/cli/image_options.rb +5 -18
  21. data/lib/lithop/cli/options.rb +4 -4
  22. data/lib/lithop/export/color_metadata.rb +46 -23
  23. data/lib/lithop/export/generation.rb +8 -10
  24. data/lib/lithop/export/planning.rb +82 -45
  25. data/lib/lithop/export/print_readme.rb +20 -4
  26. data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
  27. data/lib/lithop/formats/three_mf/model_writer.rb +2 -5
  28. data/lib/lithop/image/pipeline.rb +40 -26
  29. data/lib/lithop/image/svg_loader.rb +11 -2
  30. data/lib/lithop/layer_structure.rb +0 -9
  31. data/lib/lithop/model/filament_profile.rb +3 -3
  32. data/lib/lithop/model/material_display_map.rb +2 -1
  33. data/lib/lithop/model/z_region_model.rb +44 -0
  34. data/lib/lithop/settings.rb +147 -15
  35. data/lib/lithop/settings_file.rb +1 -0
  36. data/lib/lithop/version.rb +1 -1
  37. data/lib/lithop/web/app.rb +9 -5
  38. data/lib/lithop/web/public/app.css +48 -37
  39. data/lib/lithop/web/public/app.js +149 -47
  40. data/lib/lithop/web/settings_form.rb +8 -5
  41. data/lib/lithop/web/views/index.erb +129 -75
  42. data/lib/lithop.rb +2 -1
  43. data/sig/lithop.rbs +16 -0
  44. metadata +3 -2
  45. data/lib/lithop/model/layer_region_model.rb +0 -24
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lithop
4
+ # Restricts a material model to an arbitrary contiguous Z range.
5
+ class ZRegionModel
6
+ Cell = Data.define(:materials)
7
+
8
+ attr_reader :columns, :rows, :x_boundaries, :y_boundaries, :z_boundaries, :cells, :z_range
9
+
10
+ def initialize(model:, z_range:)
11
+ @columns = model.columns
12
+ @rows = model.rows
13
+ @x_boundaries = model.x_boundaries
14
+ @y_boundaries = model.y_boundaries
15
+ @z_range = z_range
16
+ source_layers = overlapping_layers(model.z_boundaries)
17
+ @z_boundaries = clipped_boundaries(model.z_boundaries, source_layers).freeze
18
+ @cells = model.cells.map do |cell|
19
+ Cell.new(materials: source_layers.map { |layer| cell.materials.fetch(layer) }.freeze)
20
+ end.freeze
21
+ end
22
+
23
+ def materials = @materials ||= cells.flat_map(&:materials).uniq.freeze
24
+
25
+ private
26
+
27
+ def overlapping_layers(boundaries)
28
+ layers = (0...(boundaries.length - 1)).select do |layer|
29
+ boundaries.fetch(layer) < z_range.end && boundaries.fetch(layer + 1) > z_range.begin
30
+ end
31
+ raise InputError, "Z region must overlap the material model" if layers.empty?
32
+
33
+ layers.freeze
34
+ end
35
+
36
+ def clipped_boundaries(boundaries, layers)
37
+ inner = layers.filter_map do |layer|
38
+ boundary = boundaries.fetch(layer + 1)
39
+ boundary if boundary > z_range.begin && boundary < z_range.end
40
+ end
41
+ [z_range.begin, *inner, z_range.end]
42
+ end
43
+ end
44
+ end
@@ -119,10 +119,9 @@ module Lithop
119
119
 
120
120
  def validate_z_parts!
121
121
  raise ConfigurationError, "separate_z_parts must be a boolean" unless [true, false].include?(separate_z_parts)
122
- unless color_region_parts.is_a?(Integer) && color_region_parts.positive? &&
123
- color_region_parts <= color_layer_count
124
- raise ConfigurationError, "color_region_parts must be an integer from 1 to color_layer_count"
125
- end
122
+ return if color_region_parts.is_a?(Integer) && color_region_parts.between?(1, Settings::MAXIMUM_Z_PARTS)
123
+
124
+ raise ConfigurationError, "color_region_parts must be an integer from 1 to #{Settings::MAXIMUM_Z_PARTS}"
126
125
  end
127
126
 
128
127
  def validate_layer_structure!
@@ -187,6 +186,17 @@ module Lithop
187
186
 
188
187
  raise ConfigurationError, "#{key} must be a finite percentage from -100 to 100"
189
188
  end
189
+ unless yellow_mix_percent.is_a?(Numeric) && yellow_mix_percent.finite? &&
190
+ yellow_mix_percent.between?(0.0, 100.0)
191
+ raise ConfigurationError, "yellow_mix_percent must be a finite percentage from 0 to 100"
192
+ end
193
+ unless Settings::YELLOW_MIX_COLORS.include?(yellow_mix_color)
194
+ raise ConfigurationError,
195
+ "yellow_mix_color must be one of: #{Settings::YELLOW_MIX_COLORS.join(", ")}"
196
+ end
197
+ return if [true, false].include?(suppress_pale_colors)
198
+
199
+ raise ConfigurationError, "suppress_pale_colors must be a boolean"
190
200
  end
191
201
 
192
202
  def validate_nozzle_diameter!
@@ -227,16 +237,27 @@ module Lithop
227
237
  # Validated dimensions, print parameters, and output limits for a generation job.
228
238
  # rubocop:disable-next Metrics/ClassLength
229
239
  class Settings
240
+ MAXIMUM_Z_PARTS = 3
241
+ MINIMUM_Z_PART_THICKNESS = 0.32
242
+ MINIMUM_Z_SOURCE_THICKNESS = MINIMUM_Z_PART_THICKNESS * MAXIMUM_Z_PARTS
243
+ Z_SEPARATED_COLOR_LAYER_COUNT = 8
244
+ Z_SEPARATED_FIRST_LAYER_HEIGHT = 0.1
245
+ Z_SEPARATED_LAYER_HEIGHT = 0.08
246
+ Z_PART_BASE_WHITE_LAYERS = 1
247
+ Z_PART_MINIMUM_TOTAL_LAYERS = 4
230
248
  include SettingsValidation
231
249
 
232
250
  MICRONS_PER_MM = LayerStructure::MICRONS_PER_MM
233
251
  COLOR_MODELS = %w[cmy].freeze
234
252
  NOZZLE_DIAMETERS = [0.2, 0.4].freeze
235
- COLOR_LAYER_COUNTS = [4, 5, 7].freeze
253
+ COLOR_LAYER_COUNTS = [4, 5, 7, 8].freeze
236
254
  LAYOUT_PROFILES = ["standard"].freeze
237
255
  IMAGE_FITS = %w[cover contain].freeze
238
256
  TILE_LAYOUTS = %w[auto columns rows grid].freeze
257
+ YELLOW_MIX_COLORS = %w[cyan magenta].freeze
239
258
  PIGMENTS = %i[cyan magenta yellow].freeze
259
+ PIGMENT_ORDERS = PIGMENTS.permutation.map(&:freeze).freeze
260
+ DEFAULT_PIGMENT_ORDER = %i[yellow magenta cyan].freeze
240
261
  MINIMUM_TOP_WHITE_LAYERS = LayerStructure::MINIMUM_TOP_WHITE_LAYERS
241
262
  DEFAULTS = {
242
263
  layout_profile: "standard",
@@ -253,8 +274,11 @@ module Lithop
253
274
  brightness: 0.0,
254
275
  contrast: 0.0,
255
276
  saturation: 0.0,
256
- minimum_thickness: 1.0,
257
- maximum_thickness: 2.14689422,
277
+ suppress_pale_colors: false,
278
+ yellow_mix_color: "cyan",
279
+ yellow_mix_percent: 0.0,
280
+ minimum_thickness: 0.74,
281
+ maximum_thickness: 1.94,
258
282
  pixel_size: 0.22,
259
283
  color_pixel_size: nil,
260
284
  grid_columns: 637,
@@ -266,16 +290,16 @@ module Lithop
266
290
  separate_z_parts: false,
267
291
  color_region_parts: 3,
268
292
  first_layer_height: 0.1,
269
- layer_height: 0.1,
270
- base_white_layers: 2,
271
- top_layer_height: 0.04,
272
- top_white_start: 1.0,
273
- minimum_top_white_layers: 0,
274
- exact_maximum_thickness: true,
293
+ layer_height: 0.08,
294
+ base_white_layers: 1,
295
+ top_layer_height: 0.08,
296
+ top_white_start: nil,
297
+ minimum_top_white_layers: 1,
298
+ exact_maximum_thickness: false,
275
299
  nozzle_diameter: 0.2,
276
300
  color_model: "cmy",
277
301
  color_layer_count: 7,
278
- pigment_order: PIGMENTS,
302
+ pigment_order: DEFAULT_PIGMENT_ORDER,
279
303
  bambu_project: true,
280
304
  maximum_input_pixels: 20_000_000,
281
305
  maximum_triangles: 5_000_000
@@ -290,6 +314,7 @@ module Lithop
290
314
  settings = DEFAULTS.merge(values).merge(layout_profile: profile)
291
315
  DEFAULTS.each_key { |key| instance_variable_set("@#{key}", settings.fetch(key)) }
292
316
  @pigment_order = normalize_pigment_order(@pigment_order)
317
+ apply_z_separated_layer_structure! if @separate_z_parts
293
318
  validate!
294
319
  @layer_structure = LayerStructure.new(
295
320
  first_layer_height:, layer_height:, color_layer_count:, minimum_thickness:, maximum_thickness:,
@@ -304,6 +329,7 @@ module Lithop
304
329
 
305
330
  validate_physical_minimum!
306
331
  validate_separated_top_white!
332
+ validate_z_separated_thickness_range!
307
333
  end
308
334
 
309
335
  def without_default_grid(values)
@@ -315,6 +341,18 @@ module Lithop
315
341
  end
316
342
  private :without_default_grid
317
343
 
344
+ def apply_z_separated_layer_structure!
345
+ @color_layer_count = Z_SEPARATED_COLOR_LAYER_COUNT
346
+ @first_layer_height = Z_SEPARATED_FIRST_LAYER_HEIGHT
347
+ @layer_height = Z_SEPARATED_LAYER_HEIGHT
348
+ @top_layer_height = Z_SEPARATED_LAYER_HEIGHT
349
+ @top_white_start = ((@base_white_layers * @first_layer_height) +
350
+ (@color_layer_count * @layer_height)).round(9)
351
+ @minimum_thickness = [@minimum_thickness, @top_white_start + @top_layer_height].max.round(9)
352
+ @exact_maximum_thickness = false
353
+ end
354
+ private :apply_z_separated_layer_structure!
355
+
318
356
  def columns = sampling_grid.columns
319
357
  def rows = sampling_grid.rows
320
358
  def effective_color_pixel_size = color_pixel_size || pixel_size
@@ -334,6 +372,77 @@ module Lithop
334
372
  def resolved_tile_rows = resolved_tile_shape.fetch(0)
335
373
  def resolved_tile_columns = resolved_tile_shape.fetch(1)
336
374
 
375
+ def resolved_z_part_boundaries
376
+ @resolved_z_part_boundaries ||= begin
377
+ start = layer_structure.base_white_thickness
378
+ source_thickness = z_separated_minimum_thickness / color_region_parts
379
+ boundaries = Array.new(color_region_parts) do |index|
380
+ (start + (index * source_thickness)).round(9)
381
+ end
382
+ [*boundaries, z_separated_source_end].freeze
383
+ end
384
+ end
385
+
386
+ def resolved_z_part_ranges
387
+ @resolved_z_part_ranges ||= resolved_z_part_boundaries.each_cons(2).map do |start, finish|
388
+ start...finish
389
+ end.freeze
390
+ end
391
+
392
+ def resolved_z_color_ranges
393
+ color_start = layer_structure.color_region_start
394
+ color_end = layer_structure.color_region_end
395
+ resolved_z_part_ranges.map do |range|
396
+ [range.begin, color_start].max...[range.end, color_end].min
397
+ end.freeze
398
+ end
399
+
400
+ def resolved_z_part_count = color_region_parts
401
+
402
+ def z_separated_minimum_thickness
403
+ MINIMUM_Z_SOURCE_THICKNESS.round(9)
404
+ end
405
+
406
+ def z_separated_source_end
407
+ (layer_structure.base_white_thickness + z_separated_minimum_thickness).round(9)
408
+ end
409
+
410
+ def z_part_base_white_layers(index = 0)
411
+ return Z_PART_BASE_WHITE_LAYERS unless separate_z_parts
412
+
413
+ source_layers = (z_part_minimum_source_thickness(index) / layer_height).round
414
+ [Z_PART_BASE_WHITE_LAYERS, Z_PART_MINIMUM_TOTAL_LAYERS - source_layers].max
415
+ end
416
+
417
+ def z_part_base_white_thickness(index = 0)
418
+ layers = z_part_base_white_layers(index)
419
+ (first_layer_height + ((layers - 1) * layer_height)).round(9)
420
+ end
421
+
422
+ def z_part_minimum_build_thickness
423
+ resolved_z_part_ranges.each_index.map do |index|
424
+ (z_part_minimum_source_thickness(index) + z_part_base_white_thickness(index)).round(9)
425
+ end.min
426
+ end
427
+
428
+ def z_separated_output_minimum_thickness
429
+ without_source_base = effective_minimum_thickness - layer_structure.base_white_thickness
430
+ (without_source_base + z_part_base_white_thicknesses.sum).round(9)
431
+ end
432
+
433
+ def z_separated_output_maximum_thickness
434
+ without_source_base = effective_maximum_thickness - layer_structure.base_white_thickness
435
+ (without_source_base + z_part_base_white_thicknesses.sum).round(9)
436
+ end
437
+
438
+ def z_part_base_white_layers_by_plate
439
+ resolved_z_part_ranges.each_index.map { |index| z_part_base_white_layers(index) }.freeze
440
+ end
441
+
442
+ def z_part_base_white_thicknesses
443
+ resolved_z_part_ranges.each_index.map { |index| z_part_base_white_thickness(index) }.freeze
444
+ end
445
+
337
446
  def estimated_triangles
338
447
  (4 * columns * rows) + (4 * (columns + rows))
339
448
  end
@@ -372,6 +481,15 @@ module Lithop
372
481
  effective_color_pixel_size: effective_color_pixel_size,
373
482
  effective_minimum_thickness: effective_minimum_thickness,
374
483
  effective_maximum_thickness: effective_maximum_thickness,
484
+ minimum_z_part_thickness: MINIMUM_Z_PART_THICKNESS,
485
+ z_part_base_white_layers: Z_PART_BASE_WHITE_LAYERS,
486
+ z_part_base_white_thickness: z_part_base_white_thickness,
487
+ z_part_minimum_build_thickness: z_part_minimum_build_thickness,
488
+ resolved_z_part_count: resolved_z_part_count,
489
+ z_separated_minimum_thickness: z_separated_minimum_thickness,
490
+ z_separated_output_minimum_thickness: z_separated_output_minimum_thickness,
491
+ z_separated_output_maximum_thickness: z_separated_output_maximum_thickness,
492
+ z_separated_source_end: z_separated_source_end,
375
493
  columns: columns, rows: rows, pixel_size_x: pixel_size_x, pixel_size_y: pixel_size_y,
376
494
  resolved_tile_rows: resolved_tile_rows, resolved_tile_columns: resolved_tile_columns
377
495
  )
@@ -379,6 +497,12 @@ module Lithop
379
497
 
380
498
  private
381
499
 
500
+ def z_part_minimum_source_thickness(index)
501
+ range = resolved_z_part_ranges.fetch(index)
502
+ source_end = [range.end, effective_minimum_thickness].min
503
+ [source_end - range.begin, 0.0].max.round(9)
504
+ end
505
+
382
506
  def automatic_tile_shape
383
507
  factors = factor_shapes.map do |row_count, column_count|
384
508
  tile_width = width.fdiv(column_count)
@@ -402,9 +526,17 @@ module Lithop
402
526
 
403
527
  def validate_separated_top_white!
404
528
  return unless separate_z_parts
405
- return if effective_minimum_thickness > layer_structure.color_region_end
529
+ return if z_separated_source_end > layer_structure.color_region_end
406
530
 
407
531
  raise ConfigurationError, "separate_z_parts requires a positive top White thickness"
408
532
  end
533
+
534
+ def validate_z_separated_thickness_range!
535
+ return unless separate_z_parts
536
+ return if z_separated_source_end <= effective_maximum_thickness
537
+
538
+ raise ConfigurationError,
539
+ "Z separation requires maximum_thickness of at least #{z_separated_source_end} mm"
540
+ end
409
541
  end
410
542
  end
@@ -39,6 +39,7 @@ module Lithop
39
39
  values[:image_fit] = values[:image_fit].downcase if values[:image_fit].is_a?(String)
40
40
  values[:layout_profile] = values[:layout_profile].downcase if values[:layout_profile].is_a?(String)
41
41
  values[:tile_layout] = values[:tile_layout].downcase if values[:tile_layout].is_a?(String)
42
+ values[:yellow_mix_color] = values[:yellow_mix_color].downcase if values[:yellow_mix_color].is_a?(String)
42
43
  values
43
44
  end
44
45
  private_class_method :normalize_values
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lithop
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -155,7 +155,7 @@ module Lithop
155
155
  content_type :json
156
156
  JSON.generate(
157
157
  tile_count: resolved.tile_count, tile_rows: layout.rows, tile_columns: layout.columns,
158
- z_plate_count: resolved.separate_z_parts ? resolved.color_region_parts + 1 : 1
158
+ z_plate_count: resolved.separate_z_parts ? resolved.resolved_z_part_count : 1
159
159
  )
160
160
  rescue Error, SystemCallError => e
161
161
  halt 422, json_error(e.message)
@@ -360,10 +360,7 @@ module Lithop
360
360
  def prepare_page(submitted: nil)
361
361
  form = SettingsForm.new(project_dir: settings.project_dir)
362
362
  @values = form.values
363
- if submitted
364
- SettingsForm::FIELD_NAMES.each { |key| @values[key] = submitted[key] if submitted.key?(key) }
365
- @values["separate_z_parts"] = submitted["separate_z_parts"] == "1"
366
- end
363
+ apply_submitted_values(submitted) if submitted
367
364
  @jobs = settings.job_store.recent.map do |job|
368
365
  job.merge(settings.job_store.summary(job.fetch("job_id")))
369
366
  end
@@ -378,6 +375,13 @@ module Lithop
378
375
  @csrf_token = settings.csrf_token
379
376
  end
380
377
 
378
+ def apply_submitted_values(submitted)
379
+ SettingsForm::FIELD_NAMES.each { |key| @values[key] = submitted[key] if submitted.key?(key) }
380
+ SettingsForm::BOOLEAN_FIELDS.each do |key|
381
+ @values[key.to_s] = submitted[key.to_s] == "1"
382
+ end
383
+ end
384
+
381
385
  def prepare_navigation
382
386
  @selected_job_id = params["job"]
383
387
  requested_view = params["view"].to_s
@@ -10,6 +10,7 @@
10
10
  --yellow: #f4d800;
11
11
  --green: #1f6d50;
12
12
  --danger: #a83131;
13
+ --site-header-height: 116px;
13
14
  font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
15
  }
15
16
 
@@ -31,7 +32,8 @@ button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-v
31
32
  outline-offset: 2px;
32
33
  }
33
34
 
34
- .site-header { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; gap: 14px; min-height: 64px; padding: 9px max(20px, calc((100vw - 1800px) / 2)); border-bottom: 1px solid rgba(217,215,205,.9); background: rgba(243,241,233,.94); box-shadow: 0 8px 28px rgba(38,48,42,.06); backdrop-filter: blur(14px); }
35
+ .site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid rgba(217,215,205,.9); background: rgba(243,241,233,.94); box-shadow: 0 8px 28px rgba(38,48,42,.06); backdrop-filter: blur(14px); }
36
+ .site-header-main { display: flex; align-items: center; gap: 14px; min-height: 64px; padding: 9px max(20px, calc((100vw - 1800px) / 2)); }
35
37
  .brand-block { display: grid; gap: 1px; min-width: 128px; }
36
38
  .brand { color: var(--ink); font: 500 1.8rem/.9 Georgia,"Times New Roman",serif; letter-spacing: -.045em; text-decoration: none; }
37
39
  .eyebrow, .step { margin: 0 0 10px; color: var(--green); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.17em; }
@@ -40,30 +42,32 @@ button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-v
40
42
  .header-context .project-name { overflow: hidden; max-width: 220px; text-overflow: ellipsis; white-space: nowrap; }
41
43
  .header-context strong { color: var(--ink); }
42
44
  .global-job-control { display: flex; align-items: center; gap: 4px; margin-left: auto; border: 1px solid var(--line); border-radius: 11px; padding: 3px; background: rgba(255,255,255,.72); }
43
- .header-action, .menu-toggle { border: 0; border-radius: 8px; padding: 8px 10px; color: var(--ink); background: transparent; cursor: pointer; font-size: .74rem; font-weight: 800; }
44
- .header-action:hover, .menu-toggle:hover { background: #e6e9e3; }
45
+ .header-action { border: 0; border-radius: 8px; padding: 8px 10px; color: var(--ink); background: transparent; cursor: pointer; font-size: .74rem; font-weight: 800; }
46
+ .header-action:hover { background: #e6e9e3; }
45
47
  #global-job-link { display: flex; align-items: baseline; gap: 8px; }
46
48
  #global-job-link small { color: var(--muted); font: .68rem ui-monospace,monospace; }
47
- .menu-toggle { display: none; font-size: 1.1rem; }
48
49
  .local-badge, .state-pill, .dimension-badge { border: 1px solid var(--line); border-radius: 999px; padding: 8px 13px; background: rgba(255, 255, 255, 0.72); font: 700 0.72rem ui-monospace, SFMono-Regular, Menlo, monospace; }
49
50
  .context-badge { border-radius: 999px; padding: 7px 12px; color: var(--green); background: #e8f2ec; font-size: 0.72rem; font-weight: 800; }
50
51
 
51
52
  .workspace { width: min(1800px, calc(100% - 40px)); margin-inline: auto; padding: 20px 0 80px; }
52
- .app-shell { display: grid; grid-template-columns: 160px minmax(0,1fr); align-items: start; gap: 18px; }
53
+ .app-shell { min-width: 0; }
53
54
  .app-content, .app-view { min-width: 0; }
54
55
  .view-title { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
55
- .primary-navigation { position: sticky; top: 84px; display: grid; gap: 7px; }
56
- .primary-navigation button { display: grid; grid-template-columns: 26px minmax(0,1fr); gap: 1px 7px; width: 100%; border: 1px solid transparent; border-radius: 12px; padding: 11px; color: var(--ink); background: transparent; text-align: left; cursor: pointer; font-weight: 800; }
57
- .primary-navigation button span { grid-row: 1 / 3; color: var(--muted); font: .66rem ui-monospace,monospace; }
58
- .primary-navigation button small { color: var(--muted); font-size: .66rem; font-weight: 500; }
56
+ .primary-navigation { display: flex; gap: 4px; overflow-x: auto; padding: 0 max(20px, calc((100vw - 1800px) / 2)); scrollbar-width: thin; }
57
+ .primary-navigation button { flex: none; display: flex; align-items: baseline; gap: 7px; border: 0; border-bottom: 3px solid transparent; border-radius: 7px 7px 0 0; padding: 10px 14px 9px; color: var(--ink); background: transparent; cursor: pointer; }
58
+ .primary-navigation button span { color: var(--muted); font: .66rem ui-monospace,monospace; }
59
+ .primary-navigation button strong { font-size: .8rem; }
60
+ .primary-navigation button small { color: var(--muted); font-size: .68rem; font-weight: 500; }
59
61
  .primary-navigation button:hover { background: rgba(255,255,255,.62); }
60
- .primary-navigation button[aria-current="page"] { border-color: var(--line); background: var(--panel); box-shadow: 0 8px 24px rgba(38,48,42,.06); }
62
+ .primary-navigation button[aria-current="page"] { border-bottom-color: var(--green); background: rgba(255,255,255,.72); }
61
63
  .workspace-primary { display: grid; grid-template-columns: minmax(360px,400px) minmax(0,1fr); align-items: start; gap: 18px; }
62
64
  .jobs-workspace { display: grid; grid-template-columns: minmax(300px,350px) minmax(0,1fr); grid-template-areas: "history detail"; align-items: start; gap: 18px; }
63
- .jobs-workspace .history-panel { grid-area: history; position: sticky; top: 84px; max-height: calc(100vh - 104px); overflow: auto; }
65
+ .presets-workspace { display: grid; grid-template-columns: minmax(240px,300px) minmax(0,880px); align-items: start; gap: 18px; }
66
+ .jobs-workspace .history-panel { grid-area: history; position: sticky; top: calc(var(--site-header-height) + 20px); max-height: calc(100vh - var(--site-header-height) - 40px); overflow: auto; }
67
+ .preset-list-panel { position: sticky; top: calc(var(--site-header-height) + 20px); }
64
68
  .jobs-workspace .job-panel { grid-area: detail; }
65
69
  .panel { min-width: 0; border: 1px solid var(--line); border-radius: 22px; background: rgba(255, 253, 248, 0.96); box-shadow: 0 18px 50px rgba(38, 48, 42, 0.07); padding: clamp(20px, 3vw, 34px); }
66
- .preview-panel { position: sticky; top: 84px; }
70
+ .preview-panel { position: sticky; top: calc(var(--site-header-height) + 20px); }
67
71
  .notice { padding: 13px 16px; border-radius: 12px; }
68
72
  .notice.error { color: var(--danger); background: #fff0ed; border: 1px solid #efc0b8; }
69
73
  .notice.info { color: #405049; background: #eef4f0; border: 1px solid #cbd9d0; }
@@ -80,7 +84,15 @@ button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-v
80
84
  .file-title { font-weight: 800; }
81
85
  #file-name { color: var(--muted); font-size: 0.84rem; }
82
86
 
83
- .settings-section { margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,255,0.5); }
87
+ .conversion-actions { display: grid; gap: 9px; margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; padding: 10px; background: rgba(255,253,248,.97); box-shadow: 0 10px 28px rgba(20,35,29,.11); }
88
+ .conversion-action-row { display: flex; align-items: center; gap: 14px; }
89
+ #draft-state { flex: 1; color: var(--muted); font-size: .76rem; line-height: 1.45; }
90
+ .conversion-actions .notice { margin: 0; }
91
+ .settings-navigation { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
92
+ .settings-navigation button { border: 1px solid var(--line); border-radius: 999px; padding: 7px 10px; color: var(--muted); background: white; cursor: pointer; font-size: .72rem; font-weight: 800; }
93
+ .settings-navigation button:hover { border-color: #aeb8b2; color: var(--ink); }
94
+ .settings-navigation button[aria-current="true"] { border-color: var(--green); color: var(--green); background: #edf7f0; }
95
+ .settings-section { scroll-margin-top: calc(var(--site-header-height) + 20px); margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,255,0.5); }
84
96
  .settings-section > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 18px; padding: 15px 16px; cursor: pointer; list-style: none; font-weight: 800; }
85
97
  .settings-section > summary::-webkit-details-marker { display: none; }
86
98
  .settings-section > summary::after { content: "+"; order: 3; color: var(--green); font-size: 1.2rem; }
@@ -109,16 +121,21 @@ input:invalid:not(:placeholder-shown) { border-color: var(--danger); }
109
121
  .danger-text { color: var(--danger); }
110
122
  .settings-library { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px 14px; padding-top: 14px; }
111
123
  .settings-library > label { display: grid; gap: 5px; color: var(--muted); font-size: .76rem; }
124
+ .preset-select-label { display: grid; gap: 6px; color: var(--muted); font-size: .76rem; }
125
+ .preset-apply-button { width: 100%; margin-top: 10px; }
126
+ .draft-apply { display: grid; gap: 6px; margin-top: 18px; border-top: 1px solid var(--line); padding-top: 16px; }
127
+ .draft-apply small { color: var(--muted); font-size: .72rem; line-height: 1.45; }
128
+ .draft-apply .secondary-button { margin-top: 3px; }
112
129
  .library-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
113
130
  .draft-actions { padding-top: 10px; border-top: 1px solid var(--line); }
114
131
  .settings-library .fine-print { grid-column: 1 / -1; margin: 0; }
115
- .settings-shortcut { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; border: 1px solid var(--line); border-radius: 14px; padding: 13px 15px; background: #f8f8f3; }
116
- .settings-shortcut div { display: grid; gap: 3px; }
117
- .settings-shortcut small, .preset-intro { color: var(--muted); font-size: .74rem; line-height: 1.5; }
118
- .settings-shortcut .secondary-button { flex: none; padding: 9px 12px; font-size: .74rem; }
119
- .preset-panel { max-width: 880px; }
132
+ .preset-intro { color: var(--muted); font-size: .74rem; line-height: 1.5; }
120
133
  .preset-panel .settings-library { margin-top: 18px; border-top: 1px solid var(--line); }
121
- .draft-summary { margin-top: 16px; border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; background: #f8f8f3; }
134
+ .draft-summary { border-top: 1px solid var(--line); padding: 9px 3px 1px; }
135
+ .draft-summary > summary { cursor: pointer; list-style: none; }
136
+ .draft-summary > summary::-webkit-details-marker { display: none; }
137
+ .draft-summary > summary::after { content: "詳細を見る"; color: var(--green); font-size: .7rem; font-weight: 800; }
138
+ .draft-summary[open] > summary::after { content: "詳細を閉じる"; }
122
139
  .summary-heading { display: flex; justify-content: space-between; gap: 16px; font-size: 0.8rem; }
123
140
  .summary-heading span { color: var(--muted); }
124
141
  .summary-heading span[data-changed="true"] { color: #925c00; font-weight: 800; }
@@ -126,8 +143,6 @@ input:invalid:not(:placeholder-shown) { border-color: var(--danger); }
126
143
  .draft-summary dt { color: var(--muted); }
127
144
  .draft-summary dd { overflow-wrap: anywhere; margin: 0; }
128
145
 
129
- .submit-bar { position: sticky; bottom: 12px; z-index: 4; display: flex; align-items: center; gap: 14px; margin-top: 18px; padding: 10px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,253,248,0.95); box-shadow: 0 10px 30px rgba(20,35,29,0.12); backdrop-filter: blur(10px); }
130
- #draft-state { flex: 1; color: var(--muted); font-size: 0.76rem; }
131
146
  .primary-button, .secondary-button, .danger-button { border: 0; border-radius: 11px; padding: 12px 18px; font-weight: 800; cursor: pointer; }
132
147
  .primary-button { color: white; background: var(--ink); }
133
148
  .primary-button:hover { background: var(--green); }
@@ -218,9 +233,12 @@ button:disabled { opacity: 0.5; cursor: wait; }
218
233
 
219
234
  .history-controls { display: grid; grid-template-columns: minmax(180px,2fr) repeat(4,minmax(130px,1fr)); gap: 10px; margin-bottom: 14px; }
220
235
  .history-controls label { display: grid; gap: 4px; color: var(--muted); font-size: 0.72rem; }
221
- .history-selection-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 9px; margin-bottom: 14px; }
222
- .history-selection-actions span { margin-right: auto; color: var(--muted); font-size: .76rem; }
223
- .history-selection-actions button { padding: 8px 12px; font-size: .74rem; }
236
+ .history-selection-toolbar { display: grid; gap: 9px; margin-bottom: 14px; border: 1px solid var(--line); border-radius: 12px; padding: 10px; background: #f8f7f1; }
237
+ .history-selection-summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 30px; }
238
+ .history-selection-summary span { min-width: 0; color: var(--muted); font-size: .76rem; }
239
+ .history-selection-summary .text-button { flex: none; padding: 6px 4px; }
240
+ .history-selection-actions { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 8px; }
241
+ .history-selection-actions button { width: 100%; min-height: 38px; padding: 8px; font-size: .72rem; }
224
242
  .job-list { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; }
225
243
  .job-list li { position: relative; display: grid; grid-template-columns: auto minmax(0,1fr); align-items: start; gap: 8px; min-width: 0; }
226
244
  .job-list .history-select { width: 18px; height: 18px; margin-top: 13px; accent-color: var(--green); }
@@ -232,8 +250,6 @@ button:disabled { opacity: 0.5; cursor: wait; }
232
250
  .job-list small, .empty-copy { color: var(--muted); }
233
251
  .job-list .history-thumbnail { width: 100%; aspect-ratio: 140 / 104; object-fit: contain; border-radius: 6px; background: #f0efe9; }
234
252
  .jobs-workspace .history-controls, .jobs-workspace .job-list { grid-template-columns: 1fr; }
235
- .jobs-workspace .history-selection-actions { justify-content: flex-start; }
236
- .jobs-workspace .history-selection-actions span { flex-basis: 100%; }
237
253
  .history-delete { grid-column: 2; margin-top: 6px; border: 0; padding: 3px 1px; color: #8d2525; background: transparent; cursor: pointer; font-size: 0.72rem; }
238
254
  .load-more { display: block; margin: 18px auto 0; }
239
255
  .preview-dialog { width: min(1320px, calc(100vw - 32px)); max-width: none; height: min(880px, calc(100vh - 32px)); max-height: none; border: 1px solid var(--line); border-radius: 20px; padding: 22px; color: var(--ink); background: var(--panel); box-shadow: 0 28px 90px rgba(10,20,15,.35); }
@@ -264,12 +280,6 @@ button:disabled { opacity: 0.5; cursor: wait; }
264
280
  [hidden] { display: none !important; }
265
281
 
266
282
  @media (max-width: 1439px) {
267
- .menu-toggle { display: block; }
268
- .app-shell { grid-template-columns: 1fr; }
269
- .primary-navigation { position: fixed; z-index: 31; top: 0; bottom: 0; left: 0; width: min(280px,calc(100vw - 52px)); align-content: start; padding: 78px 16px 20px; background: var(--panel); box-shadow: 18px 0 50px rgba(20,35,29,.2); transform: translateX(-105%); transition: transform 160ms ease; }
270
- body.navigation-open .primary-navigation { transform: translateX(0); }
271
- .navigation-scrim { position: fixed; z-index: 30; inset: 0; background: rgba(20,35,29,.42); }
272
- body.navigation-open .navigation-scrim { display: block !important; }
273
283
  .workspace-primary { grid-template-columns: minmax(340px,.8fr) minmax(0,1.2fr); }
274
284
  .jobs-workspace { grid-template-columns: minmax(290px,340px) minmax(0,1fr); }
275
285
  }
@@ -277,6 +287,8 @@ button:disabled { opacity: 0.5; cursor: wait; }
277
287
  @media (max-width: 900px) {
278
288
  .workspace-primary { grid-template-columns: 1fr; }
279
289
  .preview-panel { position: static; }
290
+ .presets-workspace { grid-template-columns: 1fr; }
291
+ .preset-list-panel { position: static; }
280
292
  .jobs-workspace { grid-template-columns: 1fr; grid-template-areas: "history" "detail"; }
281
293
  .jobs-workspace .history-panel { position: static; max-height: none; }
282
294
  .workspace.has-job[data-view="jobs"]:not(.show-job-list) .jobs-workspace .history-panel { display: none; }
@@ -289,7 +301,8 @@ button:disabled { opacity: 0.5; cursor: wait; }
289
301
  }
290
302
 
291
303
  @media (max-width: 680px) {
292
- .site-header { gap: 8px; padding-inline: 11px; }
304
+ .site-header-main { gap: 8px; padding-inline: 11px; }
305
+ .primary-navigation { padding-inline: 11px; }
293
306
  .brand-block { min-width: 80px; }
294
307
  .brand-block .eyebrow, .header-context .project-name, .header-context > span[aria-hidden], #help-button { display: none; }
295
308
  .brand { font-size: 1.5rem; }
@@ -305,11 +318,9 @@ button:disabled { opacity: 0.5; cursor: wait; }
305
318
  .dialog-preview-grid .result-preview { max-height: calc((100vh - 250px) / 2); }
306
319
  .draft-summary dl { grid-template-columns: 1fr; gap: 2px; }
307
320
  .draft-summary dd { margin-bottom: 7px; }
308
- .submit-bar { align-items: stretch; flex-direction: column; }
309
- .submit-bar .primary-button { width: 100%; }
321
+ .conversion-action-row { align-items: stretch; flex-direction: column; }
322
+ .conversion-action-row .primary-button { width: 100%; }
310
323
  .settings-section summary small { display: none; }
311
- .settings-shortcut { align-items: stretch; flex-direction: column; }
312
- .settings-shortcut .secondary-button { width: 100%; }
313
324
  .job-detail-tabs { overflow-x: auto; }
314
325
  }
315
326